Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions core/src/main/scala/sttp/openai/OpenAI.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand All @@ -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")

Expand Down
79 changes: 79 additions & 0 deletions core/src/main/scala/sttp/openai/OpenAISyncClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
}
Original file line number Diff line number Diff line change
@@ -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]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how can you deserialise a File from JSON? what's the intended content of this field?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ok, it's the File below, not java.io.File :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is just some file metadata :) Perhaps I should rename this class (FileMetadata) to make it clear that this is not an actual file?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yes, this is a good idea, let's do that :)

}

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]
}
47 changes: 47 additions & 0 deletions core/src/test/scala/sttp/openai/fixtures/UploadFixture.scala
Original file line number Diff line number Diff line change
@@ -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

}
Loading