diff --git a/core/src/main/scala/sttp/openai/OpenAI.scala b/core/src/main/scala/sttp/openai/OpenAI.scala index b6d12a53..11f1985f 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 67fc62bb..7a39fd21 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 00000000..4f0ad02f --- /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 00000000..db98748e --- /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/core/src/test/scala/sttp/openai/fixtures/UploadFixture.scala b/core/src/test/scala/sttp/openai/fixtures/UploadFixture.scala new file mode 100644 index 00000000..c98241fa --- /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 00000000..41ec2ffc --- /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 + } + +}