Repository: softwaremill/sttp-openai
Pull request #282 (2025-02-26 00:07:21)

Update logback-classic to 1.5.17
## About this PR
📦 Updates [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from `1.5.12` to `1.5.17`

## Usage
✅ **Please merge!**

I'll automatically update this PR to resolve conflicts as long as you don't change it yourself.

If you'd like to skip this version, you can just close this PR. If you have any feedback, just mention me in the comments below.

Configure Scala Steward for your repository with a [`.scala-steward.conf`](https://github.com/scala-steward-org/scala-steward/blob/8929fadb6f535472bd63397b1cb55a835fa1b5cb/docs/repo-specific-configuration.md) file.

_Have a fantastic day writing Scala!_

<details>
<summary>⚙ Adjust future updates</summary>

Add this to your `.scala-steward.conf` file to ignore future updates of this dependency:
```
updates.ignore = [ { groupId = "ch.qos.logback", artifactId = "logback-classic" } ]
```
Or, add this to slow down future updates of this dependency:
```
dependencyOverrides = [{
  pullRequests = { frequency = "30 days" },
  dependency = { groupId = "ch.qos.logback", artifactId = "logback-classic" }
}]
```
</details>

<sup>
labels: library-update, early-semver-patch, semver-spec-patch, commit-count:1
</sup>

<!-- scala-steward = {
  "Update" : {
    "ForArtifactId" : {
      "crossDependency" : [
        {
          "groupId" : "ch.qos.logback",
          "artifactId" : {
            "name" : "logback-classic",
            "maybeCrossName" : null
          },
          "version" : "1.5.12",
          "sbtVersion" : null,
          "scalaVersion" : null,
          "configurations" : null
        },
        {
          "groupId" : "ch.qos.logback",
          "artifactId" : {
            "name" : "logback-classic",
            "maybeCrossName" : null
          },
          "version" : "1.5.12",
          "sbtVersion" : null,
          "scalaVersion" : null,
          "configurations" : "test"
        }
      ],
      "newerVersions" : [
        "1.5.17"
      ],
      "newerGroupId" : null,
      "newerArtifactId" : null
    }
  },
  "Labels" : [
    "library-update",
    "early-semver-patch",
    "semver-spec-patch",
    "commit-count:1"
  ]
} -->


Patch:
diff --git a/build.sbt b/build.sbt
index 51c2055..cb54e66 100644
--- a/build.sbt
+++ b/build.sbt
@@ -96,7 +96,7 @@ lazy val examples = (projectMatrix in file("examples"))
   .settings(commonSettings)
   .settings(
     libraryDependencies ++= Seq(
-      "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % "1.11.16",
+      "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % "1.11.17",
       "ch.qos.logback" % "logback-classic" % "1.5.6"
     ) ++ Libraries.sttpClientOx,
     publish / skip := true
diff --git a/core/src/main/scala/sttp/openai/OpenAI.scala b/core/src/main/scala/sttp/openai/OpenAI.scala
index b6d12a5..11f1985 100644
--- a/core/src/main/scala/sttp/openai/OpenAI.scala
+++ b/core/src/main/scala/sttp/openai/OpenAI.scala
@@ -48,6 +48,7 @@ import sttp.openai.requests.threads.messages.ThreadMessagesRequestBody.CreateMes
 import sttp.openai.requests.threads.messages.ThreadMessagesResponseData.{DeleteMessageResponse, ListMessagesResponse, MessageData}
 import sttp.openai.requests.threads.runs.ThreadRunsRequestBody._
 import sttp.openai.requests.threads.runs.ThreadRunsResponseData.{ListRunStepsResponse, ListRunsResponse, RunData, RunStepData}
+import sttp.openai.requests.upload.{CompleteUploadRequestBody, UploadPartResponse, UploadRequestBody, UploadResponse}
 import sttp.openai.requests.vectorstore.VectorStoreRequestBody.{CreateVectorStoreBody, ModifyVectorStoreBody}
 import sttp.openai.requests.vectorstore.VectorStoreResponseData.{DeleteVectorStoreResponse, ListVectorStoresResponse, VectorStore}
 import sttp.openai.requests.vectorstore.file.VectorStoreFileRequestBody.{CreateVectorStoreFileBody, ListVectorStoreFilesBody}
@@ -647,6 +648,95 @@ class OpenAI(authToken: String, baseUri: Uri = OpenAIUris.OpenAIBaseUri) {
       }
       .response(asJson_parseErrors[AudioResponse])
 
+  /** Creates an intermediate Upload object that you can add Parts to. Currently, an Upload can accept at most 8 GB in total and expires
+    * after an hour after you create it.
+    *
+    * Once you complete the Upload, we will create a File object that contains all the parts you uploaded. This File is usable in the rest
+    * of our platform as a regular File object.
+    *
+    * For certain purposes, the correct mime_type must be specified. Please refer to documentation for the supported MIME types for your use
+    * case:
+    *
+    * null.
+    *
+    * For guidance on the proper filename extensions for each purpose, please follow the documentation on creating a File.
+    *
+    * [[https://platform.openai.com/docs/api-reference/uploads/create]]
+    *
+    * @param uploadRequestBody
+    *   Request body that will be used to create an upload.
+    *
+    * @return
+    *   The Upload object with status pending.
+    */
+  def createUpload(uploadRequestBody: UploadRequestBody): Request[Either[OpenAIException, UploadResponse]] =
+    openAIAuthRequest
+      .post(openAIUris.Uploads)
+      .body(uploadRequestBody)
+      .response(asJson_parseErrors[UploadResponse])
+
+  /** Adds a Part to an Upload object. A Part represents a chunk of bytes from the file you are trying to upload.
+    *
+    * Each Part can be at most 64 MB, and you can add Parts until you hit the Upload maximum of 8 GB.
+    *
+    * It is possible to add multiple Parts in parallel. You can decide the intended order of the Parts when you complete the Upload.
+    *
+    * [[https://platform.openai.com/docs/api-reference/uploads/add-part]]
+    *
+    * @param uploadId
+    *   The ID of the Upload.
+    * @param data
+    *   The chunk of bytes for this Part.
+    *
+    * @return
+    *   The upload Part object.
+    */
+  def addUploadPart(uploadId: String, data: File): Request[Either[OpenAIException, UploadPartResponse]] =
+    openAIAuthRequest
+      .post(openAIUris.uploadParts(uploadId))
+      .multipartBody(multipartFile("data", data))
+      .response(asJson_parseErrors[UploadPartResponse])
+
+  /** Completes the Upload.
+    *
+    * Within the returned Upload object, there is a nested File object that is ready to use in the rest of the platform.
+    *
+    * You can specify the order of the Parts by passing in an ordered list of the Part IDs.
+    *
+    * The number of bytes uploaded upon completion must match the number of bytes initially specified when creating the Upload object. No
+    * Parts may be added after an Upload is completed.
+    *
+    * [[https://platform.openai.com/docs/api-reference/uploads/complete]]
+    *
+    * @param uploadId
+    *   The ID of the Upload.
+    * @param requestBody
+    *   Request body that will be used to complete an upload.
+    *
+    * @return
+    *   The Upload object with status completed with an additional file property containing the created usable File object.
+    */
+  def completeUpload(uploadId: String, requestBody: CompleteUploadRequestBody): Request[Either[OpenAIException, UploadResponse]] =
+    openAIAuthRequest
+      .post(openAIUris.completeUpload(uploadId))
+      .body(requestBody)
+      .response(asJson_parseErrors[UploadResponse])
+
+  /** Cancels the Upload. No Parts may be added after an Upload is cancelled.
+    *
+    * [[https://platform.openai.com/docs/api-reference/uploads/cancel]]
+    *
+    * @param uploadId
+    *   The ID of the Upload.
+    *
+    * @return
+    *   The Upload object with status cancelled.
+    */
+  def cancelUpload(uploadId: String): Request[Either[OpenAIException, UploadResponse]] =
+    openAIAuthRequest
+      .post(openAIUris.cancelUpload(uploadId))
+      .response(asJson_parseErrors[UploadResponse])
+
   /** 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.
@@ -1379,6 +1469,7 @@ private class OpenAIUris(val baseUri: Uri) {
   val Moderations: Uri = uri"$baseUri/moderations"
   val FineTuningJobs: Uri = uri"$baseUri/fine_tuning/jobs"
   val Batches: Uri = uri"$baseUri/batches"
+  val Uploads: Uri = uri"$baseUri/uploads"
   val AdminApiKeys: Uri = uri"$baseUri/organization/admin_api_keys"
   val Transcriptions: Uri = audioBase.addPath("transcriptions")
   val Translations: Uri = audioBase.addPath("translations")
@@ -1389,6 +1480,11 @@ private class OpenAIUris(val baseUri: Uri) {
   val ThreadsRuns: Uri = uri"$baseUri/threads/runs"
   val VectorStores: Uri = uri"$baseUri/vector_stores"
 
+  def upload(uploadId: String): Uri = Uploads.addPath(uploadId)
+  def uploadParts(uploadId: String): Uri = upload(uploadId).addPath("parts")
+  def completeUpload(uploadId: String): Uri = upload(uploadId).addPath("complete")
+  def cancelUpload(uploadId: String): Uri = upload(uploadId).addPath("cancel")
+
   def chatCompletion(completionId: String): Uri = ChatCompletions.addPath(completionId)
   def chatMessages(completionId: String): Uri = chatCompletion(completionId).addPath("messages")
 
diff --git a/core/src/main/scala/sttp/openai/OpenAISyncClient.scala b/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
index 67fc62b..7a39fd2 100644
--- a/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
+++ b/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
@@ -40,6 +40,7 @@ import sttp.openai.requests.threads.messages.ThreadMessagesRequestBody.CreateMes
 import sttp.openai.requests.threads.messages.ThreadMessagesResponseData.{DeleteMessageResponse, ListMessagesResponse, MessageData}
 import sttp.openai.requests.threads.runs.ThreadRunsRequestBody.{CreateRun, CreateThreadAndRun, ToolOutput}
 import sttp.openai.requests.threads.runs.ThreadRunsResponseData.{ListRunStepsResponse, ListRunsResponse, RunData, RunStepData}
+import sttp.openai.requests.upload.{CompleteUploadRequestBody, UploadPartResponse, UploadRequestBody, UploadResponse}
 import sttp.openai.requests.vectorstore.VectorStoreRequestBody.{CreateVectorStoreBody, ModifyVectorStoreBody}
 import sttp.openai.requests.vectorstore.VectorStoreResponseData.{DeleteVectorStoreResponse, ListVectorStoresResponse, VectorStore}
 import sttp.openai.requests.vectorstore.file.VectorStoreFileRequestBody.{CreateVectorStoreFileBody, ListVectorStoreFilesBody}
@@ -430,6 +431,84 @@ class OpenAISyncClient private (
   def createTranscription(transcriptionConfig: TranscriptionConfig): AudioResponse =
     sendOrThrow(openAI.createTranscription(transcriptionConfig))
 
+  /** Creates an intermediate Upload object that you can add Parts to. Currently, an Upload can accept at most 8 GB in total and expires
+    * after an hour after you create it.
+    *
+    * Once you complete the Upload, we will create a File object that contains all the parts you uploaded. This File is usable in the rest
+    * of our platform as a regular File object.
+    *
+    * For certain purposes, the correct mime_type must be specified. Please refer to documentation for the supported MIME types for your use
+    * case:
+    *
+    * null.
+    *
+    * For guidance on the proper filename extensions for each purpose, please follow the documentation on creating a File.
+    *
+    * [[https://platform.openai.com/docs/api-reference/uploads/create]]
+    *
+    * @param uploadRequestBody
+    *   Request body that will be used to create an upload.
+    *
+    * @return
+    *   The Upload object with status pending.
+    */
+  def createUpload(uploadRequestBody: UploadRequestBody): UploadResponse =
+    sendOrThrow(openAI.createUpload(uploadRequestBody))
+
+  /** Adds a Part to an Upload object. A Part represents a chunk of bytes from the file you are trying to upload.
+    *
+    * Each Part can be at most 64 MB, and you can add Parts until you hit the Upload maximum of 8 GB.
+    *
+    * It is possible to add multiple Parts in parallel. You can decide the intended order of the Parts when you complete the Upload.
+    *
+    * [[https://platform.openai.com/docs/api-reference/uploads/add-part]]
+    *
+    * @param uploadId
+    *   The ID of the Upload.
+    * @param data
+    *   The chunk of bytes for this Part.
+    *
+    * @return
+    *   The upload Part object.
+    */
+  def addUploadPart(uploadId: String, data: File): UploadPartResponse =
+    sendOrThrow(openAI.addUploadPart(uploadId, data))
+
+  /** Completes the Upload.
+    *
+    * Within the returned Upload object, there is a nested File object that is ready to use in the rest of the platform.
+    *
+    * You can specify the order of the Parts by passing in an ordered list of the Part IDs.
+    *
+    * The number of bytes uploaded upon completion must match the number of bytes initially specified when creating the Upload object. No
+    * Parts may be added after an Upload is completed.
+    *
+    * [[https://platform.openai.com/docs/api-reference/uploads/complete]]
+    *
+    * @param uploadId
+    *   The ID of the Upload.
+    * @param requestBody
+    *   Request body that will be used to complete an upload.
+    *
+    * @return
+    *   The Upload object with status completed with an additional file property containing the created usable File object.
+    */
+  def completeUpload(uploadId: String, requestBody: CompleteUploadRequestBody): UploadResponse =
+    sendOrThrow(openAI.completeUpload(uploadId, requestBody))
+
+  /** Cancels the Upload. No Parts may be added after an Upload is cancelled.
+    *
+    * [[https://platform.openai.com/docs/api-reference/uploads/cancel]]
+    *
+    * @param uploadId
+    *   The ID of the Upload.
+    *
+    * @return
+    *   The Upload object with status cancelled.
+    */
+  def cancelUpload(uploadId: String): UploadResponse =
+    sendOrThrow(openAI.cancelUpload(uploadId))
+
   /** 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.
diff --git a/core/src/main/scala/sttp/openai/requests/upload/UploadRequestBody.scala b/core/src/main/scala/sttp/openai/requests/upload/UploadRequestBody.scala
new file mode 100644
index 0000000..4f0ad02
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/upload/UploadRequestBody.scala
@@ -0,0 +1,41 @@
+package sttp.openai.requests.upload
+
+import sttp.openai.json.SnakePickle
+
+/** Represents the request body for uploading a file.
+  *
+  * @param filename
+  *   The name of the file to upload.
+  * @param purpose
+  *   The intended purpose of the uploaded file.
+  * @param bytes
+  *   The number of bytes in the file you are uploading.
+  * @param mimeType
+  *   The MIME type of the file.
+  *
+  * This must fall within the supported MIME types for your file purpose. See the supported MIME types for assistants and vision.
+  */
+case class UploadRequestBody(
+    filename: String,
+    purpose: String,
+    bytes: Int,
+    mimeType: String
+)
+
+object UploadRequestBody {
+  implicit val uploadRequestBodyW: SnakePickle.Writer[UploadRequestBody] = SnakePickle.macroW[UploadRequestBody]
+}
+
+/** @param partIds
+  *   The ordered list of Part IDs.
+  * @param md5
+  *   The optional md5 checksum for the file contents to verify if the bytes uploaded matches what you expect.
+  */
+case class CompleteUploadRequestBody(
+    partIds: Seq[String],
+    md5: Option[String]
+)
+
+object CompleteUploadRequestBody {
+  implicit val completeUploadRequestBodyW: SnakePickle.Writer[CompleteUploadRequestBody] = SnakePickle.macroW[CompleteUploadRequestBody]
+}
diff --git a/core/src/main/scala/sttp/openai/requests/upload/UploadResponse.scala b/core/src/main/scala/sttp/openai/requests/upload/UploadResponse.scala
new file mode 100644
index 0000000..db98748
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/upload/UploadResponse.scala
@@ -0,0 +1,75 @@
+package sttp.openai.requests.upload
+
+import sttp.openai.json.SnakePickle
+
+/** Represents the response for an upload request.
+  *
+  * @param id
+  *   The Upload unique identifier, which can be referenced in API endpoints.
+  * @param `object`
+  *   The object type, which is always "upload".
+  * @param bytes
+  *   The intended number of bytes to be uploaded.
+  * @param createdAt
+  *   The Unix timestamp (in seconds) for when the Upload was created.
+  * @param filename
+  *   The name of the file to be uploaded.
+  * @param purpose
+  *   The intended purpose of the file. Please refer here for acceptable values.
+  * @param status
+  *   The status of the Upload.
+  * @param expiresAt
+  *   The Unix timestamp (in seconds) for when the Upload will expire.
+  * @param file
+  *   The File object represents a document that has been uploaded to OpenAI.
+  */
+case class UploadResponse(
+    id: String,
+    `object`: String = "upload",
+    bytes: Int,
+    createdAt: Int,
+    filename: String,
+    purpose: String,
+    status: String,
+    expiresAt: Int,
+    file: Option[File]
+)
+
+object UploadResponse {
+  implicit val uploadResponseR: SnakePickle.Reader[UploadResponse] = SnakePickle.macroR[UploadResponse]
+}
+
+case class File(
+    id: String,
+    `object`: String,
+    bytes: Int,
+    createdAt: Int,
+    filename: String,
+    purpose: String
+)
+
+object File {
+  implicit val fileR: SnakePickle.Reader[File] = SnakePickle.macroR[File]
+}
+
+/** Represents the response for an upload part.
+  *
+  * @param id
+  *   The upload Part unique identifier, which can be referenced in API endpoints.
+  * @param createdAt
+  *   The Unix timestamp (in seconds) for when the Part was created.
+  * @param uploadId
+  *   The ID of the Upload object that this Part was added to.
+  * @param `object`
+  *   The object type, which is always upload.part.
+  */
+case class UploadPartResponse(
+    id: String,
+    createdAt: Int,
+    uploadId: String,
+    `object`: String = "upload.part"
+)
+
+object UploadPartResponse {
+  implicit val uploadPartResponseR: SnakePickle.Reader[UploadPartResponse] = SnakePickle.macroR[UploadPartResponse]
+}
diff --git a/examples/src/main/scala/examples/ChatProxy.scala b/examples/src/main/scala/examples/ChatProxy.scala
index 985bc34..75a4bb8 100644
--- a/examples/src/main/scala/examples/ChatProxy.scala
+++ b/examples/src/main/scala/examples/ChatProxy.scala
@@ -2,7 +2,7 @@
 //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.7
 //> using dep com.softwaremill.sttp.client4::ox:4.0.0-M19
 //> using dep com.softwaremill.ox::core:0.5.11
-//> using dep ch.qos.logback:logback-classic:1.5.12
+//> using dep ch.qos.logback:logback-classic:1.5.17
 
 // remember to set the OPENAI_KEY env variable!
 // run with: OPENAI_KEY=... scala-cli run ChatProxy.scala
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 7b2a6b1..2bf10bf 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -10,7 +10,7 @@ object Dependencies {
     val sttpClient = "4.0.0-M19"
     val pekkoStreams = "1.1.3"
     val akkaStreams = "2.6.20"
-    val tapir = "1.11.16"
+    val tapir = "1.11.17"
     val uPickle = "3.1.4"
   }
 
diff --git a/project/build.properties b/project/build.properties
index 96d8db7..e97b272 100644
--- a/project/build.properties
+++ b/project/build.properties
@@ -1,1 +1,1 @@
-sbt.version=1.10.9
+sbt.version=1.10.10


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/UploadFixture.scala b/core/src/test/scala/sttp/openai/fixtures/UploadFixture.scala
new file mode 100644
index 0000000..c98241f
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/UploadFixture.scala
@@ -0,0 +1,47 @@
+package sttp.openai.fixtures
+
+object UploadFixture {
+
+  val jsonCreateUpload: String =
+    """{
+      |  "filename": "file-name",
+      |  "purpose": "file-purpose",
+      |  "bytes": 123,
+      |  "mime_type": "file/mime-type"
+      |}""".stripMargin
+
+  val jsonCompleteUpload: String =
+    """{
+      |  "part_ids": ["part_abc123", "part_def456"],
+      |  "md5": "md5-checksum"
+      |}""".stripMargin
+
+  val jsonUpdateResponse: String =
+    """{
+      |  "id": "upload_abc123",
+      |  "object": "upload",
+      |  "bytes": 1147483648,
+      |  "created_at": 1719184911,
+      |  "filename": "training_examples.jsonl",
+      |  "purpose": "fine-tune",
+      |  "status": "completed",
+      |  "expires_at": 1719127296,
+      |  "file": {
+      |    "id": "file-xyz321",
+      |    "object": "file",
+      |    "bytes": 1147483648,
+      |    "created_at": 1719186911,
+      |    "filename": "training_examples.jsonl",
+      |    "purpose": "fine-tune"
+      |  }
+      |}""".stripMargin
+
+  val jsonUploadPartResponse: String =
+    """{
+       |    "id": "part_def456",
+       |    "object": "upload.part",
+       |    "created_at": 1719186911,
+       |    "upload_id": "upload_abc123"
+       |}""".stripMargin
+
+}
diff --git a/core/src/test/scala/sttp/openai/requests/upload/UploadDataSpec.scala b/core/src/test/scala/sttp/openai/requests/upload/UploadDataSpec.scala
new file mode 100644
index 0000000..41ec2ff
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/upload/UploadDataSpec.scala
@@ -0,0 +1,83 @@
+package sttp.openai.requests.upload
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.fixtures.UploadFixture
+import sttp.openai.json.{SnakePickle, SttpUpickleApiExtension}
+
+class UploadDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+
+  "Given upload request body as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = UploadRequestBody(
+      filename = "file-name",
+      purpose = "file-purpose",
+      bytes = 123,
+      mimeType = "file/mime-type"
+    )
+    val jsonRequest: ujson.Value = ujson.read(UploadFixture.jsonCreateUpload)
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given complete upload request body as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = CompleteUploadRequestBody(
+      partIds = Seq("part_abc123", "part_def456"),
+      md5 = Some("md5-checksum")
+    )
+    val jsonRequest: ujson.Value = ujson.read(UploadFixture.jsonCompleteUpload)
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given upload response as Json" should "be properly deserialized to case class" in {
+    // given
+    val jsonResponse = UploadFixture.jsonUpdateResponse
+    val expectedResponse: UploadResponse = UploadResponse(
+      id = "upload_abc123",
+      bytes = 1147483648,
+      createdAt = 1719184911,
+      filename = "training_examples.jsonl",
+      purpose = "fine-tune",
+      status = "completed",
+      expiresAt = 1719127296,
+      file = Some(
+        File(
+          id = "file-xyz321",
+          bytes = 1147483648,
+          createdAt = 1719186911,
+          filename = "training_examples.jsonl",
+          purpose = "fine-tune",
+          `object` = "file"
+        )
+      )
+    )
+    // when
+    val deserializedJsonResponse: Either[Exception, UploadResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[UploadResponse].apply(jsonResponse)
+    // then
+    deserializedJsonResponse.value shouldBe expectedResponse
+  }
+
+  "Given upload part response as Json" should "be properly deserialized to case class" in {
+    // given
+    val jsonResponse = UploadFixture.jsonUploadPartResponse
+    val expectedResponse: UploadPartResponse = UploadPartResponse(
+      id = "part_def456",
+      createdAt = 1719186911,
+      uploadId = "upload_abc123"
+    )
+    // when
+    val deserializedJsonResponse: Either[Exception, UploadPartResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[UploadPartResponse].apply(jsonResponse)
+    // then
+    deserializedJsonResponse.value shouldBe expectedResponse
+  }
+
+}