Repository: softwaremill/sttp-openai
Pull request #283 (2025-02-26 11:37:45)

Add fine tuning api


Patch:
diff --git a/core/src/main/scala/sttp/openai/OpenAI.scala b/core/src/main/scala/sttp/openai/OpenAI.scala
index 923d4ac..953495c 100644
--- a/core/src/main/scala/sttp/openai/OpenAI.scala
+++ b/core/src/main/scala/sttp/openai/OpenAI.scala
@@ -24,6 +24,8 @@ import sttp.openai.requests.completions.chat.ChatRequestResponseData.ChatRespons
 import sttp.openai.requests.embeddings.EmbeddingsRequestBody.EmbeddingsBody
 import sttp.openai.requests.embeddings.EmbeddingsResponseBody.EmbeddingResponse
 import sttp.openai.requests.files.FilesResponseData._
+import sttp.openai.requests.finetuning
+import sttp.openai.requests.finetuning._
 import sttp.openai.requests.images.ImageResponseData.ImageResponse
 import sttp.openai.requests.images.creation.ImageCreationRequestBody.ImageCreationBody
 import sttp.openai.requests.images.edit.ImageEditsConfig
@@ -82,7 +84,7 @@ class OpenAI(authToken: String, baseUri: Uri = OpenAIUris.OpenAIBaseUri) {
     * @param completionBody
     *   Create completion request body.
     * @deprecated
-    *   This is marked as Legacy in OpenAI API and might be removed in the future. Please use createChatCompletion instead.
+    *   This is marked as Legacy in OpenAI API and might be removed in the future. Please use [[createChatCompletion]] instead.
     */
   def createCompletion(completionBody: CompletionsBody): Request[Either[OpenAIException, CompletionsResponse]] =
     openAIAuthRequest
@@ -541,6 +543,100 @@ class OpenAI(authToken: String, baseUri: Uri = OpenAIUris.OpenAIBaseUri) {
       }
       .response(asJson_parseErrors[AudioResponse])
 
+  /** Creates a fine-tuning job which begins the process of creating a new model from a given dataset.
+    *
+    * Response includes details of the enqueued job including job status and the name of the fine-tuned models once complete.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/create]]
+    *
+    * @param fineTuningRequestBody
+    *   Request body that will be used to create a fine-tuning job.
+    */
+  def createFineTuningJob(fineTuningRequestBody: FineTuningJobRequestBody): Request[Either[OpenAIException, FineTuningJobResponse]] =
+    openAIAuthRequest
+      .post(openAIUris.FineTuningJobs)
+      .body(fineTuningRequestBody)
+      .response(asJson_parseErrors[FineTuningJobResponse])
+
+  /** List your organization's fine-tuning jobs
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/list]]
+    */
+  def listFineTuningJobs(
+      queryParameters: finetuning.QueryParameters = finetuning.QueryParameters.empty
+  ): Request[Either[OpenAIException, ListFineTuningJobResponse]] = {
+    val uri = openAIUris.FineTuningJobs
+      .withParams(queryParameters.toMap)
+
+    openAIAuthRequest
+      .get(uri)
+      .response(asJson_parseErrors[ListFineTuningJobResponse])
+  }
+
+  /** Get status updates for a fine-tuning job.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/list-events]]
+    *
+    * @param fineTuningJobId
+    *   The ID of the fine-tuning job to get checkpoints for.
+    */
+  def listFineTuningJobEvents(
+      fineTuningJobId: String,
+      queryParameters: finetuning.QueryParameters = finetuning.QueryParameters.empty
+  ): Request[Either[OpenAIException, ListFineTuningJobEventResponse]] = {
+    val uri = openAIUris
+      .fineTuningJobEvents(fineTuningJobId)
+      .withParams(queryParameters.toMap)
+
+    openAIAuthRequest
+      .get(uri)
+      .response(asJson_parseErrors[ListFineTuningJobEventResponse])
+  }
+
+  /** List checkpoints for a fine-tuning job.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/list-checkpoints]]
+    *
+    * @param fineTuningJobId
+    *   The ID of the fine-tuning job to get checkpoints for.
+    */
+  def listFineTuningJobCheckpoints(
+      fineTuningJobId: String,
+      queryParameters: finetuning.QueryParameters = finetuning.QueryParameters.empty
+  ): Request[Either[OpenAIException, ListFineTuningJobCheckpointResponse]] = {
+    val uri = openAIUris
+      .fineTuningJobCheckpoints(fineTuningJobId)
+      .withParams(queryParameters.toMap)
+
+    openAIAuthRequest
+      .get(uri)
+      .response(asJson_parseErrors[ListFineTuningJobCheckpointResponse])
+  }
+
+  /** Get info about a fine-tuning job.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/retrieve]]
+    *
+    * @param fineTuningJobId
+    *   The ID of the fine-tuning job.
+    */
+  def retrieveFineTuningJob(fineTuningJobId: String): Request[Either[OpenAIException, FineTuningJobResponse]] =
+    openAIAuthRequest
+      .get(openAIUris.fineTuningJob(fineTuningJobId))
+      .response(asJson_parseErrors[FineTuningJobResponse])
+
+  /** Immediately cancel a fine-tune job.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/cancel]]
+    *
+    * @param fineTuningJobId
+    *   The ID of the fine-tuning job to cancel.
+    */
+  def cancelFineTuningJob(fineTuningJobId: String): Request[Either[OpenAIException, FineTuningJobResponse]] =
+    openAIAuthRequest
+      .post(openAIUris.cancelFineTuningJob(fineTuningJobId))
+      .response(asJson_parseErrors[FineTuningJobResponse])
+
   /** Gets info about the fine-tune job.
     *
     * [[https://platform.openai.com/docs/api-reference/embeddings/create]]
@@ -1036,6 +1132,7 @@ private class OpenAIUris(val baseUri: Uri) {
   val Files: Uri = uri"$baseUri/files"
   val Models: Uri = uri"$baseUri/models"
   val Moderations: Uri = uri"$baseUri/moderations"
+  val FineTuningJobs: Uri = uri"$baseUri/fine_tuning/jobs"
   val Transcriptions: Uri = audioBase.addPath("transcriptions")
   val Translations: Uri = audioBase.addPath("translations")
   val VariationsImage: Uri = imageBase.addPath("variations")
@@ -1045,6 +1142,11 @@ private class OpenAIUris(val baseUri: Uri) {
   val ThreadsRuns: Uri = uri"$baseUri/threads/runs"
   val VectorStores: Uri = uri"$baseUri/vector_stores"
 
+  def fineTuningJob(fineTuningJobId: String): Uri = FineTuningJobs.addPath(fineTuningJobId)
+  def fineTuningJobEvents(fineTuningJobId: String): Uri = fineTuningJob(fineTuningJobId).addPath("events")
+  def fineTuningJobCheckpoints(fineTuningJobId: String): Uri = fineTuningJob(fineTuningJobId).addPath("checkpoints")
+  def cancelFineTuningJob(fineTuningJobId: String): Uri = fineTuningJob(fineTuningJobId).addPath("cancel")
+
   def file(fileId: String): Uri = Files.addPath(fileId)
   def fileContent(fileId: String): Uri = Files.addPath(fileId, "content")
   def model(modelId: String): Uri = Models.addPath(modelId)
diff --git a/core/src/main/scala/sttp/openai/OpenAISyncClient.scala b/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
index af64650..78bee40 100644
--- a/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
+++ b/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
@@ -16,6 +16,8 @@ import sttp.openai.requests.completions.chat.ChatRequestResponseData.ChatRespons
 import sttp.openai.requests.embeddings.EmbeddingsRequestBody.EmbeddingsBody
 import sttp.openai.requests.embeddings.EmbeddingsResponseBody.EmbeddingResponse
 import sttp.openai.requests.files.FilesResponseData.{DeletedFileData, FileData, FilesResponse}
+import sttp.openai.requests.finetuning
+import sttp.openai.requests.finetuning._
 import sttp.openai.requests.images.ImageResponseData.ImageResponse
 import sttp.openai.requests.images.creation.ImageCreationRequestBody.ImageCreationBody
 import sttp.openai.requests.images.edit.ImageEditsConfig
@@ -348,6 +350,71 @@ class OpenAISyncClient private (
   def createTranscription(transcriptionConfig: TranscriptionConfig): AudioResponse =
     sendOrThrow(openAI.createTranscription(transcriptionConfig))
 
+  /** Creates a fine-tuning job which begins the process of creating a new model from a given dataset.
+    *
+    * Response includes details of the enqueued job including job status and the name of the fine-tuned models once complete.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/create]]
+    *
+    * @param fineTuningRequestBody
+    *   Request body that will be used to create a fine-tuning job.
+    */
+  def createFineTuningJob(fineTuningRequestBody: FineTuningJobRequestBody): FineTuningJobResponse =
+    sendOrThrow(openAI.createFineTuningJob(fineTuningRequestBody))
+
+  /** List your organization's fine-tuning jobs
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/list]]
+    */
+  def listFineTuningJobs(queryParameters: finetuning.QueryParameters = finetuning.QueryParameters.empty): ListFineTuningJobResponse =
+    sendOrThrow(openAI.listFineTuningJobs(queryParameters))
+
+  /** Get status updates for a fine-tuning job.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/list-events]]
+    *
+    * @param fineTuningJobId
+    *   The ID of the fine-tuning job to get checkpoints for.
+    */
+  def listFineTuningJobEvents(
+      fineTuningJobId: String,
+      queryParameters: finetuning.QueryParameters = finetuning.QueryParameters.empty
+  ): ListFineTuningJobEventResponse =
+    sendOrThrow(openAI.listFineTuningJobEvents(fineTuningJobId, queryParameters))
+
+  /** List checkpoints for a fine-tuning job.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/list-checkpoints]]
+    *
+    * @param fineTuningJobId
+    *   The ID of the fine-tuning job to get checkpoints for.
+    */
+  def listFineTuningJobCheckpoints(
+      fineTuningJobId: String,
+      queryParameters: finetuning.QueryParameters = finetuning.QueryParameters.empty
+  ): ListFineTuningJobCheckpointResponse =
+    sendOrThrow(openAI.listFineTuningJobCheckpoints(fineTuningJobId, queryParameters))
+
+  /** Get info about a fine-tuning job.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/retrieve]]
+    *
+    * @param fineTuningJobId
+    *   The ID of the fine-tuning job.
+    */
+  def retrieveFineTuningJob(fineTuningJobId: String): FineTuningJobResponse =
+    sendOrThrow(openAI.retrieveFineTuningJob(fineTuningJobId))
+
+  /** Immediately cancel a fine-tune job.
+    *
+    * [[https://platform.openai.com/docs/api-reference/fine-tuning/cancel]]
+    *
+    * @param fineTuningJobId
+    *   The ID of the fine-tuning job to cancel.
+    */
+  def cancelFineTuningJob(fineTuningJobId: String): FineTuningJobResponse =
+    sendOrThrow(openAI.cancelFineTuningJob(fineTuningJobId))
+
   /** Gets info about the fine-tune job.
     *
     * [[https://platform.openai.com/docs/api-reference/embeddings/create]]
diff --git a/core/src/main/scala/sttp/openai/requests/finetuning/FineTuningJobRequestBody.scala b/core/src/main/scala/sttp/openai/requests/finetuning/FineTuningJobRequestBody.scala
new file mode 100644
index 0000000..a46d147
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetuning/FineTuningJobRequestBody.scala
@@ -0,0 +1,78 @@
+package sttp.openai.requests.finetuning
+
+import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
+import sttp.openai.json.SnakePickle
+import ujson.Str
+
+/** @param model
+  *   The name of the model to fine-tune. You can select one of the supported models
+  *   [[https://platform.openai.com/docs/guides/fine-tuning#which-models-can-be-fine-tuned]].
+  * @param trainingFile
+  *   The ID of an uploaded file that contains training data. See upload file for how to upload a file. Your dataset must be formatted as a
+  *   JSONL file. Additionally, you must upload your file with the purpose fine-tune. The contents of the file should differ depending on if
+  *   the model uses the chat, completions format, or if the fine-tuning method uses the preference format. See the fine-tuning guide for
+  *   more details.
+  * @param suffix
+  *   A string of up to 64 characters that will be added to your fine-tuned model name. For example, a suffix of "custom-model-name" would
+  *   produce a model name like ft:gpt-4o-mini:openai:custom-model-name:7p4lURel.
+  * @param validationFile
+  *   The ID of an uploaded file that contains validation data. If you provide this file, the data is used to generate validation metrics
+  *   periodically during fine-tuning. These metrics can be viewed in the fine-tuning results file. The same data should not be present in
+  *   both train and validation files. Your dataset must be formatted as a JSONL file. You must upload your file with the purpose fine-tune.
+  *   See the fine-tuning guide for more details.
+  * @param integrations
+  *   A list of integrations to enable for your fine-tuning job.
+  * @param seed
+  *   The seed controls the reproducibility of the job. Passing in the same seed and job parameters should produce the same results, but may
+  *   differ in rare cases. If a seed is not specified, one will be generated for you.
+  * @param method
+  *   The method used for fine-tuning.
+  */
+case class FineTuningJobRequestBody(
+    model: FineTuningModel,
+    trainingFile: String,
+    suffix: Option[String] = None,
+    validationFile: Option[String] = None,
+    integrations: Option[Seq[Integration]] = None,
+    seed: Option[Int] = None,
+    method: Option[Method] = None
+)
+object FineTuningJobRequestBody {
+  implicit val fineTuningRequestBodyWriter: SnakePickle.Writer[FineTuningJobRequestBody] = SnakePickle.macroW[FineTuningJobRequestBody]
+}
+
+sealed abstract class FineTuningModel(val value: String)
+
+object FineTuningModel {
+
+  implicit val fineTuningModelRW: SnakePickle.ReadWriter[FineTuningModel] = SnakePickle
+    .readwriter[ujson.Value]
+    .bimap[FineTuningModel](
+      model => SnakePickle.writeJs(model.value),
+      jsonValue =>
+        SnakePickle.read[ujson.Value](jsonValue) match {
+          case Str(value) =>
+            byFineTuningModelValue.getOrElse(value, CustomFineTuningModel(value))
+          case e => throw DeserializationOpenAIException(new Exception(s"Could not deserialize: $e"))
+        }
+    )
+
+  case object GPT4o20240806 extends FineTuningModel("gpt-4o-2024-08-06")
+
+  case object GPT4oMini20240718 extends FineTuningModel("gpt-4o-mini-2024-07-18")
+
+  case object GPT40613 extends FineTuningModel("gpt-4-0613")
+
+  case object GPT35Turbo0125 extends FineTuningModel("gpt-3.5-turbo-0125")
+
+  case object GPT35Turbo1106 extends FineTuningModel("gpt-3.5-turbo-1106")
+
+  case object GPT35Turbo0613 extends FineTuningModel("gpt-3.5-turbo-0613")
+
+  case class CustomFineTuningModel(customFineTuningModel: String) extends FineTuningModel(customFineTuningModel)
+
+  val values: Set[FineTuningModel] = Set(GPT4o20240806, GPT4oMini20240718, GPT40613, GPT35Turbo0125, GPT35Turbo1106, GPT35Turbo0613)
+
+  private val byFineTuningModelValue = values.map(model => model.value -> model).toMap
+
+}
diff --git a/core/src/main/scala/sttp/openai/requests/finetuning/FineTuningJobResponse.scala b/core/src/main/scala/sttp/openai/requests/finetuning/FineTuningJobResponse.scala
new file mode 100644
index 0000000..a528b2f
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetuning/FineTuningJobResponse.scala
@@ -0,0 +1,233 @@
+package sttp.openai.requests.finetuning
+
+import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
+import sttp.openai.json.SnakePickle
+import ujson.Str
+
+/** The fine_tuning.job object represents a fine-tuning job that has been created through the API.
+  *
+  * @param id
+  *   The object identifier, which can be referenced in the API endpoints.
+  * @param createdAt
+  *   The Unix timestamp (in seconds) for when the fine-tuning job was created.
+  * @param error
+  *   For fine-tuning jobs that have failed, this will contain more information on the cause of the failure.
+  * @param fineTunedModel
+  *   The name of the fine-tuned model that is being created. The value will be null if the fine-tuning job is still running.
+  * @param finishedAt
+  *   The Unix timestamp (in seconds) for when the fine-tuning job was finished. The value will be null if the fine-tuning job is still
+  *   running.
+  * @param hyperparameters
+  *   The hyperparameters used for the fine-tuning job. This value will only be returned when running supervised jobs.
+  * @param model
+  *   The base model that is being fine-tuned.
+  * @param `object`
+  *   The object type, which is always "fine_tuning.job".
+  * @param organizationId
+  *   The organization that owns the fine-tuning job.
+  * @param resultFiles
+  *   The compiled results file ID(s) for the fine-tuning job. You can retrieve the results with the Files API.
+  * @param status
+  *   The current status of the fine-tuning job, which can be either validating_files, queued, running, succeeded, failed, or cancelled.
+  * @param trainedTokens
+  *   The total number of billable tokens processed by this fine-tuning job. The value will be null if the fine-tuning job is still running.
+  * @param trainingFile
+  *   The file ID used for training. You can retrieve the training data with the Files API.
+  * @param validationFile
+  *   The file ID used for validation. You can retrieve the validation results with the Files API.
+  * @param integrations
+  *   A list of integrations to enable for this fine-tuning job.
+  * @param seed
+  *   The seed used for the fine-tuning job.
+  * @param estimatedFinish
+  *   The Unix timestamp (in seconds) for when the fine-tuning job is estimated to finish. The value will be null if the fine-tuning job is
+  *   not running.
+  * @param method
+  *   The method used for fine-tuning.
+  */
+case class FineTuningJobResponse(
+    id: String,
+    createdAt: Int,
+    error: Option[Error] = None,
+    fineTunedModel: Option[String],
+    finishedAt: Option[Int],
+    hyperparameters: Option[Hyperparameters],
+    model: String,
+    `object`: String,
+    organizationId: String,
+    resultFiles: Seq[String],
+    status: Status,
+    trainedTokens: Option[Int] = None,
+    trainingFile: String,
+    validationFile: Option[String],
+    integrations: Option[Seq[Integration]] = None,
+    seed: Int,
+    estimatedFinish: Option[Int] = None,
+    method: Method
+)
+
+object FineTuningJobResponse {
+  implicit val fineTuningResponseDataReader: SnakePickle.Reader[FineTuningJobResponse] = SnakePickle.macroR[FineTuningJobResponse]
+}
+
+/** @param code
+  *   A machine-readable error code.
+  * @param message
+  *   A human-readable error message.
+  * @param param
+  *   The parameter that was invalid, usually training_file or validation_file. This field will be null if the failure was not
+  *   parameter-specific.
+  */
+case class Error(
+    code: String,
+    message: String,
+    param: Option[String] = None
+)
+
+object Error {
+  implicit val errorReader: SnakePickle.Reader[Error] = SnakePickle.macroR[Error]
+}
+
+sealed abstract class Status(val value: String)
+
+object Status {
+
+  implicit val statusRW: SnakePickle.Reader[Status] = SnakePickle
+    .reader[ujson.Value]
+    .map[Status](jsonValue =>
+      SnakePickle.read[ujson.Value](jsonValue) match {
+        case Str(value) => byStatusValue.getOrElse(value, CustomStatus(value))
+        case e          => throw DeserializationOpenAIException(new Exception(s"Could not deserialize: $e"))
+      }
+    )
+
+  case object ValidatingFiles extends Status("validating_files")
+
+  case object Queued extends Status("queued")
+
+  case object Running extends Status("running")
+
+  case object Succeeded extends Status("succeeded")
+
+  case object Failed extends Status("failed")
+
+  case object Cancelled extends Status("cancelled")
+
+  case class CustomStatus(customStatus: String) extends Status(customStatus)
+
+  private val values: Set[Status] = Set(ValidatingFiles, Queued, Running, Succeeded, Failed, Cancelled)
+
+  private val byStatusValue = values.map(status => status.value -> status).toMap
+
+}
+
+case class ListFineTuningJobResponse(
+    `object`: String = "list",
+    data: Seq[FineTuningJobResponse],
+    hasMore: Boolean
+)
+
+object ListFineTuningJobResponse {
+  implicit val listFineTuningResponseR: SnakePickle.Reader[ListFineTuningJobResponse] = SnakePickle.macroR[ListFineTuningJobResponse]
+}
+
+/** Fine-tuning job event object
+  *
+  * @param `object`
+  *   The object type, which is always "fine_tuning.job.event".
+  * @param id
+  *   The object identifier.
+  * @param createdAt
+  *   The Unix timestamp (in seconds) for when the fine-tuning job was created.
+  * @param level
+  *   The log level of the event.
+  * @param message
+  *   The message of the event.
+  * @param `type`
+  *   The type of event.
+  * @param data
+  *   The data associated with the event.
+  */
+case class FineTuningJobEventResponse(
+    `object`: String,
+    id: String,
+    createdAt: Int,
+    level: String,
+    message: String,
+    `type`: String,
+    data: Map[String, ujson.Value]
+)
+
+object FineTuningJobEventResponse {
+  implicit val fineTuningJobEventResponseR: SnakePickle.Reader[FineTuningJobEventResponse] = SnakePickle.macroR[FineTuningJobEventResponse]
+}
+
+case class ListFineTuningJobEventResponse(
+    `object`: String = "list",
+    data: Seq[FineTuningJobEventResponse],
+    hasMore: Boolean
+)
+
+object ListFineTuningJobEventResponse {
+  implicit val listFineTuningJobEventResponseR: SnakePickle.Reader[ListFineTuningJobEventResponse] =
+    SnakePickle.macroR[ListFineTuningJobEventResponse]
+}
+
+/** The fine_tuning.job.checkpoint object represents a model checkpoint for a fine-tuning job that is ready to use.
+  *
+  * @param id
+  *   The checkpoint identifier, which can be referenced in the API endpoints.
+  * @param createdAt
+  *   The Unix timestamp (in seconds) for when the checkpoint was created.
+  * @param fineTunedModelCheckpoint
+  *   The name of the fine-tuned checkpoint model that is created.
+  * @param stepNumber
+  *   The step number that the checkpoint was created at.
+  * @param metrics
+  *   Metrics at the step number during the fine-tuning job.
+  * @param fineTuningJobId
+  *   The name of the fine-tuning job that this checkpoint was created from.
+  * @param `object`
+  *   The object type, which is always "fine_tuning.job.checkpoint".
+  */
+case class FineTuningJobCheckpointResponse(
+    id: String,
+    createdAt: Int,
+    fineTunedModelCheckpoint: String,
+    stepNumber: Int,
+    metrics: Metrics,
+    fineTuningJobId: String,
+    `object`: String = "fine_tuning.job.checkpoint"
+)
+
+object FineTuningJobCheckpointResponse {
+  implicit val fineTuningJobCheckpointResponseR: SnakePickle.Reader[FineTuningJobCheckpointResponse] =
+    SnakePickle.macroR[FineTuningJobCheckpointResponse]
+}
+
+case class ListFineTuningJobCheckpointResponse(
+    `object`: String = "list",
+    data: Seq[FineTuningJobCheckpointResponse],
+    firstId: String,
+    lastId: String,
+    hasMore: Boolean
+)
+
+object ListFineTuningJobCheckpointResponse {
+  implicit val listFineTuningJobCheckpointResponseR: SnakePickle.Reader[ListFineTuningJobCheckpointResponse] =
+    SnakePickle.macroR[ListFineTuningJobCheckpointResponse]
+}
+
+case class Metrics(
+    step: Float,
+    trainLoss: Float,
+    trainMeanTokenAccuracy: Float,
+    validLoss: Float,
+    validMeanTokenAccuracy: Float,
+    fullValidLoss: Float,
+    fullValidMeanTokenAccuracy: Float
+)
+
+object Metrics {
+  implicit val metricsR: SnakePickle.Reader[Metrics] = SnakePickle.macroR[Metrics]
+}
diff --git a/core/src/main/scala/sttp/openai/requests/finetuning/Hyperparameters.scala b/core/src/main/scala/sttp/openai/requests/finetuning/Hyperparameters.scala
new file mode 100644
index 0000000..904deeb
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetuning/Hyperparameters.scala
@@ -0,0 +1,24 @@
+package sttp.openai.requests.finetuning
+
+import sttp.openai.json.SnakePickle
+
+/** @param batchSize
+  *   Number of examples in each batch. A larger batch size means that model parameters are updated less frequently, but with lower
+  *   variance.
+  * @param learningRateMultiplier
+  *   Scaling factor for the learning rate. A smaller learning rate may be useful to avoid overfitting.
+  * @param nEpochs
+  *   The number of epochs to train the model for. An epoch refers to one full cycle through the training dataset.
+  * @param beta
+  *   The beta value for the DPO method. A higher beta value will increase the weight of the penalty between the policy and reference model.
+  */
+case class Hyperparameters(
+    batchSize: Option[Int] = None,
+    learningRateMultiplier: Option[Float] = None,
+    nEpochs: Option[Int] = None,
+    beta: Option[Float] = None
+)
+
+object Hyperparameters {
+  implicit val hyperparametersW: SnakePickle.ReadWriter[Hyperparameters] = SnakePickle.macroRW[Hyperparameters]
+}
diff --git a/core/src/main/scala/sttp/openai/requests/finetuning/Integration.scala b/core/src/main/scala/sttp/openai/requests/finetuning/Integration.scala
new file mode 100644
index 0000000..a9a7d6c
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetuning/Integration.scala
@@ -0,0 +1,49 @@
+package sttp.openai.requests.finetuning
+
+import sttp.openai.json.SnakePickle
+
+/** @param `type`
+  *   The type of integration to enable. Currently, only "wandb" (Weights and Biases) is supported.
+  * @param wandb
+  *   The settings for your integration with Weights and Biases. This payload specifies the project that metrics will be sent to.
+  *   Optionally, you can set an explicit display name for your run, add tags to your run, and set a default entity (team, username, etc) to
+  *   be associated with your run.
+  */
+case class Integration(
+    `type`: Type,
+    wandb: Wandb
+)
+
+object Integration {
+  implicit val integrationRW: SnakePickle.ReadWriter[Integration] = SnakePickle.macroRW[Integration]
+
+  case object Wandb extends Type("wandb")
+
+  private val values: Set[Type] = Set(Wandb)
+
+  private val byTypeValue = values.map(`type` => `type`.value -> `type`).toMap
+
+  implicit val typeRW: SnakePickle.ReadWriter[Type] = Type.typeRW(byTypeValue)
+}
+
+/** @param project
+  *   The name of the project that the new run will be created under.
+  * @param name
+  *   A display name to set for the run. If not set, we will use the Job ID as the name.
+  * @param entity
+  *   The entity to use for the run. This allows you to set the team or username of the WandB user that you would like associated with the
+  *   run. If not set, the default entity for the registered WandB API key is used.
+  * @param tags
+  *   A list of tags to be attached to the newly created run. These tags are passed through directly to WandB. Some default tags are
+  *   generated by OpenAI: "openai/finetune", "openai/{base-model}", "openai/{ftjob-abcdef}".
+  */
+case class Wandb(
+    project: String,
+    name: Option[String] = None,
+    entity: Option[String] = None,
+    tags: Option[Seq[String]]
+)
+
+object Wandb {
+  implicit val wandbRW: SnakePickle.ReadWriter[Wandb] = SnakePickle.macroRW[Wandb]
+}
diff --git a/core/src/main/scala/sttp/openai/requests/finetuning/Method.scala b/core/src/main/scala/sttp/openai/requests/finetuning/Method.scala
new file mode 100644
index 0000000..e18b88d
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetuning/Method.scala
@@ -0,0 +1,50 @@
+package sttp.openai.requests.finetuning
+
+import sttp.openai.json.SnakePickle
+
+/** @param `type`
+  *   The type of method. Is either supervised or dpo.
+  * @param supervised
+  *   Configuration for the supervised fine-tuning method.
+  * @param dpo
+  *   Configuration for the DPO fine-tuning method.
+  */
+case class Method(
+    `type`: Option[Type] = None,
+    supervised: Option[Supervised] = None,
+    dpo: Option[Dpo] = None
+)
+
+object Method {
+  implicit val methodRW: SnakePickle.ReadWriter[Method] = SnakePickle.macroRW[Method]
+
+  case object Supervised extends Type("supervised")
+
+  case object Dpo extends Type("dpo")
+
+  private val values: Set[Type] = Set(Supervised, Dpo)
+
+  implicit val byTypeValue: Map[String, Type] = values.map(`type` => `type`.value -> `type`).toMap
+}
+
+/** @param hyperparameters
+  *   The hyperparameters used for the fine-tuning job.
+  */
+case class Supervised(
+    hyperparameters: Option[Hyperparameters] = None
+)
+
+object Supervised {
+  implicit val supervisedRW: SnakePickle.ReadWriter[Supervised] = SnakePickle.macroRW[Supervised]
+}
+
+/** @param hyperparameters
+  *   The hyperparameters used for the fine-tuning job.
+  */
+case class Dpo(
+    hyperparameters: Option[Hyperparameters] = None
+)
+
+object Dpo {
+  implicit val dpoRW: SnakePickle.ReadWriter[Dpo] = SnakePickle.macroRW[Dpo]
+}
diff --git a/core/src/main/scala/sttp/openai/requests/finetuning/QueryParameters.scala b/core/src/main/scala/sttp/openai/requests/finetuning/QueryParameters.scala
new file mode 100644
index 0000000..891de51
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetuning/QueryParameters.scala
@@ -0,0 +1,24 @@
+package sttp.openai.requests.finetuning
+
+/** @param after
+  *   A cursor for use in pagination. after is an object ID that defines your place in the list. For instance, if you make a list request
+  *   and receive 100 objects, ending with obj_foo, your subsequent call can include after=obj_foo in order to fetch the next page of the
+  *   list
+  * @param limit
+  *   Defaults to 20 A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 20.
+  */
+case class QueryParameters(
+    after: Option[String] = None,
+    limit: Option[Int] = None
+) {
+
+  def toMap: Map[String, String] = {
+    val queryParams = after.map("after" -> _) ++
+      limit.map(_.toString).map("order" -> _)
+    queryParams.toMap
+  }
+}
+
+object QueryParameters {
+  val empty: QueryParameters = QueryParameters(None, None)
+}
diff --git a/core/src/main/scala/sttp/openai/requests/finetuning/Type.scala b/core/src/main/scala/sttp/openai/requests/finetuning/Type.scala
new file mode 100644
index 0000000..03078e8
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetuning/Type.scala
@@ -0,0 +1,24 @@
+package sttp.openai.requests.finetuning
+
+import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
+import sttp.openai.json.SnakePickle
+import ujson.Str
+
+abstract class Type(val value: String)
+
+object Type {
+  implicit def typeRW(implicit byTypeValue: Map[String, Type]): SnakePickle.ReadWriter[Type] = SnakePickle
+    .readwriter[ujson.Value]
+    .bimap[Type](
+      `type` => SnakePickle.writeJs(`type`.value),
+      jsonValue =>
+        SnakePickle.read[ujson.Value](jsonValue) match {
+          case Str(value) =>
+            byTypeValue.get(value) match {
+              case Some(t) => t
+              case None    => throw DeserializationOpenAIException(new Exception(s"Could not deserialize: $value"))
+            }
+          case e => throw DeserializationOpenAIException(new Exception(s"Could not deserialize: $e"))
+        }
+    )
+}


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/FineTuningJobFixture.scala b/core/src/test/scala/sttp/openai/fixtures/FineTuningJobFixture.scala
new file mode 100644
index 0000000..91bf8ae
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/FineTuningJobFixture.scala
@@ -0,0 +1,248 @@
+package sttp.openai.fixtures
+
+import sttp.openai.requests.finetuning.Status.Running
+import sttp.openai.requests.finetuning.{Dpo, Error, FineTuningJobResponse, Hyperparameters, Integration, Method, Supervised, Wandb}
+
+object FineTuningJobFixture {
+
+  val jsonCreateFineTuneJobRequest: String = """{
+                                            |  "model": "gpt-3.5-turbo-0125",
+                                            |  "training_file": "file-train231",
+                                            |  "suffix": "sml-model-name",
+                                            |  "validation_file": "file-valid2",
+                                            |  "integrations": [
+                                            |    {
+                                            |      "type": "wandb",
+                                            |      "wandb": {
+                                            |        "project": "sml-project",
+                                            |        "name": "sml",
+                                            |        "entity": "sml-entity",
+                                            |        "tags": [
+                                            |          "sml-tag"
+                                            |        ]
+                                            |      }
+                                            |    }
+                                            |  ],
+                                            |  "seed": 3,
+                                            |  "method": {
+                                            |    "type": "supervised",
+                                            |    "supervised": {
+                                            |      "hyperparameters": {
+                                            |        "batch_size": 2,
+                                            |        "learning_rate_multiplier": 0.2,
+                                            |        "n_epochs": 4
+                                            |      }
+                                            |    },
+                                            |    "dpo": {
+                                            |      "hyperparameters": {
+                                            |        "batch_size": 3,
+                                            |        "learning_rate_multiplier": 0.5,
+                                            |        "n_epochs": 5,
+                                            |        "beta": 0.7
+                                            |      }
+                                            |    }
+                                            |  }
+                                            |}""".stripMargin
+
+  val jsonCreateFineTuneJobResponse: String = """{
+                                             |  "id": "ft-id",
+                                             |  "created_at": 1000,
+                                             |  "error": {
+                                             |    "code": "error-code",
+                                             |    "message": "error-message",
+                                             |    "param": "error-param"
+                                             |  },
+                                             |  "fine_tuned_model": "ft-model",
+                                             |  "finished_at": 2000,
+                                             |  "hyperparameters": {
+                                             |    "batch_size": 2,
+                                             |    "learning_rate_multiplier": 0.2,
+                                             |    "n_epochs": 4
+                                             |  },
+                                             |  "model": "ft-model",
+                                             |  "object": "ft-object",
+                                             |  "organization_id": "ft-org-id",
+                                             |  "result_files": [
+                                             |    "ft-rf"
+                                             |  ],
+                                             |  "status": "running",
+                                             |  "trained_tokens": 3000,
+                                             |  "training_file": "ft-tf",
+                                             |  "validation_file": "ft-vf",
+                                             |  "integrations": [
+                                             |    {
+                                             |      "type": "wandb",
+                                             |      "wandb": {
+                                             |        "project": "sml-project",
+                                             |        "name": "sml",
+                                             |        "entity": "sml-entity",
+                                             |        "tags": [
+                                             |          "sml-tag"
+                                             |        ]
+                                             |      }
+                                             |    }
+                                             |  ],
+                                             |  "seed": 1,
+                                             |  "estimated_finish": 4000,
+                                             |  "method": {
+                                             |    "type": "supervised",
+                                             |    "supervised": {
+                                             |      "hyperparameters": {
+                                             |        "batch_size": 2,
+                                             |        "learning_rate_multiplier": 0.2,
+                                             |        "n_epochs": 4
+                                             |      }
+                                             |    },
+                                             |    "dpo": {
+                                             |      "hyperparameters": {
+                                             |        "batch_size": 3,
+                                             |        "learning_rate_multiplier": 0.5,
+                                             |        "n_epochs": 5,
+                                             |        "beta": 0.7
+                                             |      }
+                                             |    }
+                                             |  }
+                                             |}""".stripMargin
+
+  val jsonListFineTuningJobResponse: String = s"""{
+                                           |  "object": "list",
+                                           |  "data": [
+                                           |  $jsonCreateFineTuneJobResponse
+                                           |  ],
+                                           |  "hasMore": false
+                                           |}""".stripMargin
+
+  val jsonListFineTuningJobEventsResponse: String = """{
+                                                      |  "object": "list",
+                                                      |  "data": [
+                                                      |    {
+                                                      |      "object": "fine_tuning.job.event",
+                                                      |      "id": "ft-event-ddTJfwuMVpfLXseO0Am0Gqjm",
+                                                      |      "created_at": 1721764800,
+                                                      |      "level": "info",
+                                                      |      "message": "Fine tuning job successfully completed",
+                                                      |      "data": null,
+                                                      |      "type": "message"
+                                                      |    },
+                                                      |    {
+                                                      |      "object": "fine_tuning.job.event",
+                                                      |      "id": "ft-event-tyiGuB72evQncpH87xe505Sv",
+                                                      |      "created_at": 1721764800,
+                                                      |      "level": "info",
+                                                      |      "message": "New fine-tuned model created: ft:gpt-4o-mini:openai::7p4lURel",
+                                                      |      "data": {},
+                                                      |      "type": "message"
+                                                      |    },
+                                                      |    {
+                                                      |      "object": "fine_tuning.job.event",
+                                                      |      "id": "ft-AF1WoRqd3aJAHsqc9NY7iL8F",
+                                                      |      "created_at": 1721764800,
+                                                      |      "level": "error",
+                                                      |      "message": "Fine-tuning job failed.",
+                                                      |      "data": {
+                                                      |        "job_id": "ft-AF1WoRqd3aJAHsqc9NY7iL8F",
+                                                      |        "error": "Insufficient training data."
+                                                      |      },
+                                                      |      "type": "message"
+                                                      |    }
+                                                      |  ],
+                                                      |  "has_more": true
+                                                      |}""".stripMargin
+
+  val jsonListFineTuningJobCheckpointsResponse: String = """{
+                                                      |  "object": "list",
+                                                      |  "data": [
+                                                      |    {
+                                                      |      "object": "fine_tuning.job.checkpoint",
+                                                      |      "id": "ftckpt_zc4Q7MP6XxulcVzj4MZdwsAB",
+                                                      |      "created_at": 1721764867,
+                                                      |      "fine_tuned_model_checkpoint": "ft:gpt-4o-mini-2024-07-18:my-org:custom-suffix:96olL566:ckpt-step-2000",
+                                                      |      "metrics": {
+                                                      |        "full_valid_loss": 0.134,
+                                                      |        "full_valid_mean_token_accuracy": 0.874,
+                                                      |        "step": 0.123,
+                                                      |        "train_loss": 0.346,
+                                                      |        "train_mean_token_accuracy": 0.736,
+                                                      |        "valid_loss": 0.654,
+                                                      |        "valid_mean_token_accuracy": 0.738
+                                                      |      },
+                                                      |      "fine_tuning_job_id": "ftjob-abc123",
+                                                      |      "step_number": 2000
+                                                      |    }
+                                                      |  ],
+                                                      |  "first_id": "ftckpt_zc4Q7MP6XxulcVzj4MZdwsAB",
+                                                      |  "last_id": "ftckpt_enQCFmOTGj3syEpYVhBRLTSy",
+                                                      |  "has_more": true
+                                                      |}""".stripMargin
+
+  val fineTuningJobResponse: FineTuningJobResponse = FineTuningJobResponse(
+    id = "ft-id",
+    createdAt = 1000,
+    error = Some(
+      Error(
+        code = "error-code",
+        message = "error-message",
+        param = Some("error-param")
+      )
+    ),
+    fineTunedModel = Some("ft-model"),
+    finishedAt = Some(2000),
+    hyperparameters = Some(
+      Hyperparameters(
+        batchSize = Some(2),
+        learningRateMultiplier = Some(0.2f),
+        nEpochs = Some(4)
+      )
+    ),
+    model = "ft-model",
+    `object` = "ft-object",
+    organizationId = "ft-org-id",
+    resultFiles = Seq("ft-rf"),
+    status = Running,
+    trainedTokens = Some(3000),
+    trainingFile = "ft-tf",
+    validationFile = Some("ft-vf"),
+    integrations = Some(
+      Seq(
+        Integration(
+          `type` = Integration.Wandb,
+          wandb = Wandb(
+            project = "sml-project",
+            name = Some("sml"),
+            entity = Some("sml-entity"),
+            tags = Some(Seq("sml-tag"))
+          )
+        )
+      )
+    ),
+    seed = 1,
+    estimatedFinish = Some(4000),
+    method = Method(
+      `type` = Some(Method.Supervised),
+      supervised = Some(
+        Supervised(
+          hyperparameters = Some(
+            Hyperparameters(
+              batchSize = Some(2),
+              learningRateMultiplier = Some(0.2f),
+              nEpochs = Some(4)
+            )
+          )
+        )
+      ),
+      dpo = Some(
+        Dpo(
+          hyperparameters = Some(
+            Hyperparameters(
+              batchSize = Some(3),
+              learningRateMultiplier = Some(0.5f),
+              nEpochs = Some(5),
+              beta = Some(0.7f)
+            )
+          )
+        )
+      )
+    )
+  )
+
+}
diff --git a/core/src/test/scala/sttp/openai/requests/finetuning/FineTuningDataSpec.scala b/core/src/test/scala/sttp/openai/requests/finetuning/FineTuningDataSpec.scala
new file mode 100644
index 0000000..c1ca45a
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/finetuning/FineTuningDataSpec.scala
@@ -0,0 +1,171 @@
+package sttp.openai.requests.finetuning
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.fixtures.FineTuningJobFixture
+import sttp.openai.json.{SnakePickle, SttpUpickleApiExtension}
+import sttp.openai.requests.finetuning.FineTuningModel.GPT35Turbo0125
+import ujson.Str
+
+class FineTuningDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+
+  "Given create fine tuning job request as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = FineTuningJobRequestBody(
+      model = GPT35Turbo0125,
+      trainingFile = "file-train231",
+      suffix = Some("sml-model-name"),
+      validationFile = Some("file-valid2"),
+      integrations = Some(
+        Seq(
+          Integration(
+            `type` = Integration.Wandb,
+            wandb = Wandb(
+              project = "sml-project",
+              name = Some("sml"),
+              entity = Some("sml-entity"),
+              tags = Some(Seq("sml-tag"))
+            )
+          )
+        )
+      ),
+      seed = Some(3),
+      method = Some(
+        Method(
+          `type` = Some(Method.Supervised),
+          supervised = Some(
+            Supervised(
+              hyperparameters = Some(
+                Hyperparameters(
+                  batchSize = Some(2),
+                  learningRateMultiplier = Some(0.2f),
+                  nEpochs = Some(4)
+                )
+              )
+            )
+          ),
+          dpo = Some(
+            Dpo(
+              hyperparameters = Some(
+                Hyperparameters(
+                  batchSize = Some(3),
+                  learningRateMultiplier = Some(0.5f),
+                  nEpochs = Some(5),
+                  beta = Some(0.7f)
+                )
+              )
+            )
+          )
+        )
+      )
+    )
+    val jsonRequest: ujson.Value = ujson.read(FineTuningJobFixture.jsonCreateFineTuneJobRequest)
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create fine tuning job response as Json" should "be properly deserialized to case class" in {
+    // given
+    val jsonResponse = FineTuningJobFixture.jsonCreateFineTuneJobResponse
+    val expectedResponse: FineTuningJobResponse = FineTuningJobFixture.fineTuningJobResponse
+    // when
+    val deserializedJsonResponse: Either[Exception, FineTuningJobResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[FineTuningJobResponse].apply(jsonResponse)
+    // then
+    deserializedJsonResponse.value shouldBe expectedResponse
+  }
+
+  "Given list fine tuning jobs response as Json" should "be properly deserialized to case class" in {
+    // given
+    val jsonResponse = FineTuningJobFixture.jsonListFineTuningJobResponse
+    val expectedResponse: ListFineTuningJobResponse = ListFineTuningJobResponse(
+      data = Seq(FineTuningJobFixture.fineTuningJobResponse),
+      hasMore = false
+    )
+    // when
+    val deserializedJsonResponse: Either[Exception, ListFineTuningJobResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[ListFineTuningJobResponse].apply(jsonResponse)
+
+    // then
+    deserializedJsonResponse.value shouldBe expectedResponse
+  }
+
+  "Given list fine tuning job events response as Json" should "be properly deserialized to case class" in {
+    // given
+    val jsonResponse = FineTuningJobFixture.jsonListFineTuningJobEventsResponse
+    val expectedResponse: ListFineTuningJobEventResponse = ListFineTuningJobEventResponse(
+      data = Seq(
+        FineTuningJobEventResponse(
+          `object` = "fine_tuning.job.event",
+          id = "ft-event-ddTJfwuMVpfLXseO0Am0Gqjm",
+          createdAt = 1721764800,
+          level = "info",
+          message = "Fine tuning job successfully completed",
+          data = null,
+          `type` = "message"
+        ),
+        FineTuningJobEventResponse(
+          `object` = "fine_tuning.job.event",
+          id = "ft-event-tyiGuB72evQncpH87xe505Sv",
+          createdAt = 1721764800,
+          level = "info",
+          message = "New fine-tuned model created: ft:gpt-4o-mini:openai::7p4lURel",
+          data = Map(),
+          `type` = "message"
+        ),
+        FineTuningJobEventResponse(
+          `object` = "fine_tuning.job.event",
+          id = "ft-AF1WoRqd3aJAHsqc9NY7iL8F",
+          createdAt = 1721764800,
+          level = "error",
+          message = "Fine-tuning job failed.",
+          data = Map("job_id" -> Str("ft-AF1WoRqd3aJAHsqc9NY7iL8F"), "error" -> Str("Insufficient training data.")),
+          `type` = "message"
+        )
+      ),
+      hasMore = true
+    )
+    // when
+    val deserializedJsonResponse: Either[Exception, ListFineTuningJobEventResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[ListFineTuningJobEventResponse].apply(jsonResponse)
+    // then
+    deserializedJsonResponse.value shouldBe expectedResponse
+  }
+
+  "Given list fine tuning job checkpoints response as Json" should "be properly deserialized to case class" in {
+    // given
+    val jsonResponse = FineTuningJobFixture.jsonListFineTuningJobCheckpointsResponse
+    val expectedResponse: ListFineTuningJobCheckpointResponse = ListFineTuningJobCheckpointResponse(
+      data = Seq(
+        FineTuningJobCheckpointResponse(
+          id = "ftckpt_zc4Q7MP6XxulcVzj4MZdwsAB",
+          createdAt = 1721764867,
+          fineTunedModelCheckpoint = "ft:gpt-4o-mini-2024-07-18:my-org:custom-suffix:96olL566:ckpt-step-2000",
+          metrics = Metrics(
+            fullValidLoss = 0.134f,
+            fullValidMeanTokenAccuracy = 0.874f,
+            step = 0.123f,
+            trainLoss = 0.346f,
+            trainMeanTokenAccuracy = 0.736f,
+            validLoss = 0.654f,
+            validMeanTokenAccuracy = 0.738f
+          ),
+          fineTuningJobId = "ftjob-abc123",
+          stepNumber = 2000
+        )
+      ),
+      firstId = "ftckpt_zc4Q7MP6XxulcVzj4MZdwsAB",
+      lastId = "ftckpt_enQCFmOTGj3syEpYVhBRLTSy",
+      hasMore = true
+    )
+    // when
+    val deserializedJsonResponse: Either[Exception, ListFineTuningJobCheckpointResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[ListFineTuningJobCheckpointResponse].apply(jsonResponse)
+    // then
+    deserializedJsonResponse.value shouldBe expectedResponse
+  }
+
+}