Repository: softwaremill/sttp-openai
Pull request #147 (2024-01-12 00:05:38)

Update upickle to 3.1.4
## About this PR
📦 Updates [com.lihaoyi:upickle](https://github.com/com-lihaoyi/upickle) from `3.1.3` to `3.1.4`

📜 [GitHub Release Notes](https://github.com/com-lihaoyi/upickle/releases/tag/3.1.4) - [Release Notes](https://com-lihaoyi.github.io/upickle/#VersionHistory) - [Version Diff](https://github.com/com-lihaoyi/upickle/compare/3.1.3...3.1.4)

## 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/9ac858c7e61f43ed3648f0fabc7104d0951cce67/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 = "com.lihaoyi", artifactId = "upickle" } ]
```
Or, add this to slow down future updates of this dependency:
```
dependencyOverrides = [{
  pullRequests = { frequency = "30 days" },
  dependency = { groupId = "com.lihaoyi", artifactId = "upickle" }
}]
```
</details>

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


Patch:
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index 5b38b35..7a97abc 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -1,2 +1,5 @@
 # Scala Steward: Reformat with scalafmt 3.7.17
 266a025b23369b5c3ef80bb96bccc87888c3971c
+
+# Scala Steward: Reformat with scalafmt 3.8.0
+5b2bb84be0a9e9d3334b7d23c1e56dd47c0acc50
diff --git a/.scalafmt.conf b/.scalafmt.conf
index 424d8a3..695d228 100644
--- a/.scalafmt.conf
+++ b/.scalafmt.conf
@@ -1,4 +1,4 @@
-version        = 3.7.17
+version        = 3.8.0
 maxColumn      = 140
 rewrite.rules  = [RedundantBraces, RedundantParens, SortImports]
 runner.dialect = scala3
diff --git a/README.md b/README.md
index db3870f..8679da2 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Sttp-openai uses sttp client to describe requests and responses used in OpenAI e
 Add the following dependency:
 
 ```sbt
-"com.softwaremill.sttp.openai" %% "core" % "0.0.9"
+"com.softwaremill.sttp.openai" %% "core" % "0.0.10"
 ```
 
 sttp openai is available for Scala 2.13 and Scala 3
diff --git a/core/src/main/scala/sttp/openai/OpenAI.scala b/core/src/main/scala/sttp/openai/OpenAI.scala
index 7fb6bec..113010f 100644
--- a/core/src/main/scala/sttp/openai/OpenAI.scala
+++ b/core/src/main/scala/sttp/openai/OpenAI.scala
@@ -1,9 +1,18 @@
 package sttp.openai
 
 import sttp.client4._
-import sttp.model.Uri
+import sttp.model.{Header, Uri}
 import sttp.openai.OpenAIExceptions.OpenAIException
 import sttp.openai.json.SttpUpickleApiExtension.{asJsonSnake, asStreamSnake, asStringEither, upickleBodySerializer}
+import sttp.openai.requests.assistants.AssistantsRequestBody.{CreateAssistantBody, CreateAssistantFileBody, ModifyAssistantBody}
+import sttp.openai.requests.assistants.AssistantsResponseData.{
+  AssistantData,
+  AssistantFileData,
+  DeleteAssistantFileResponse,
+  DeleteAssistantResponse,
+  ListAssistantFilesResponse,
+  ListAssistantsResponse
+}
 import sttp.openai.requests.completions.CompletionsRequestBody.CompletionsBody
 import sttp.openai.requests.completions.CompletionsResponseData.CompletionsResponse
 import sttp.openai.requests.completions.chat.ChatRequestBody.ChatBody
@@ -32,6 +41,24 @@ import sttp.openai.requests.audio.transcriptions.TranscriptionConfig
 import sttp.openai.requests.audio.translations.TranslationConfig
 import sttp.openai.requests.audio.RecognitionModel
 import sttp.capabilities.Streams
+import sttp.openai.requests.threads.ThreadsRequestBody.CreateThreadBody
+import sttp.openai.requests.threads.ThreadsResponseData.{DeleteThreadResponse, ThreadData}
+import sttp.openai.requests.threads.messages.ThreadMessagesRequestBody.CreateMessage
+import sttp.openai.requests.threads.messages.ThreadMessagesResponseData.{
+  ListMessageFilesResponse,
+  ListMessagesResponse,
+  MessageData,
+  MessageFileData
+}
+import sttp.openai.requests.threads.runs.ThreadRunsRequestBody.{
+  CreateRun,
+  CreateThreadAndRun,
+  ModifyRun,
+  SubmitToolOutputsToRun,
+  ToolOutput
+}
+import sttp.openai.requests.threads.runs.ThreadRunsResponseData.{ListRunStepsResponse, ListRunsResponse, RunData, RunStepData}
+import sttp.openai.requests.threads.QueryParameters
 
 import java.io.File
 import java.nio.file.Paths
@@ -603,8 +630,456 @@ class OpenAI(authToken: String) {
       .get(OpenAIUris.fineTuneEvents(fineTuneId))
       .response(asJsonSnake[FineTuneEventsResponse])
 
-  private val openAIAuthRequest: PartialRequest[Either[String, String]] = basicRequest.auth
+  /** Create a thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/threads/createThread]]
+    *
+    * @param createThreadBody
+    *   Create completion request body.
+    */
+  def createThread(createThreadBody: CreateThreadBody): Request[Either[OpenAIException, ThreadData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.Threads)
+      .body(createThreadBody)
+      .response(asJsonSnake[ThreadData])
+
+  /** Retrieves a thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/threads/getThread]]
+    *
+    * @param threadId
+    *   The ID of the thread to retrieve.
+    */
+  def retrieveThread(threadId: String): Request[Either[OpenAIException, ThreadData]] =
+    betaOpenAIAuthRequest
+      .get(OpenAIUris.thread(threadId))
+      .response(asJsonSnake[ThreadData])
+
+  /** Modifies a thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/threads/modifyThread]]
+    *
+    * @param threadId
+    *   The ID of the thread to modify. Only the metadata can be modified.
+    */
+  def modifyThread(threadId: String, metadata: Map[String, String]): Request[Either[OpenAIException, ThreadData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.thread(threadId))
+      .body(metadata)
+      .response(asJsonSnake[ThreadData])
+
+  /** Delete a thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/threads/deleteThread]]
+    *
+    * @param threadId
+    *   The ID of the thread to delete.
+    */
+  def deleteThread(threadId: String): Request[Either[OpenAIException, DeleteThreadResponse]] =
+    betaOpenAIAuthRequest
+      .delete(OpenAIUris.thread(threadId))
+      .response(asJsonSnake[DeleteThreadResponse])
+
+  /** Create a message.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/createMessage]]
+    *
+    * @param threadId
+    *   The ID of the thread to create a message for.
+    */
+  def createThreadMessage(threadId: String, message: CreateMessage): Request[Either[OpenAIException, MessageData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.threadMessages(threadId))
+      .body(message)
+      .response(asJsonSnake[MessageData])
+
+  /** Returns a list of messages for a given thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/listMessages]]
+    *
+    * @param threadId
+    *   The ID of the thread the messages belong to.
+    */
+  def listThreadMessages(
+      threadId: String,
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): Request[Either[OpenAIException, ListMessagesResponse]] = {
+    val uri = OpenAIUris
+      .threadMessages(threadId)
+      .withParams(queryParameters.toMap)
+
+    betaOpenAIAuthRequest
+      .get(uri)
+      .response(asJsonSnake[ListMessagesResponse])
+  }
+
+  /** Returns a list of message files.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/listMessageFiles]]
+    *
+    * @param threadId
+    *   The ID of the thread that the message and files belong to.
+    *
+    * @param messageId
+    *   The ID of the message that the files belongs to.
+    */
+  def listThreadMessageFiles(
+      threadId: String,
+      messageId: String,
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): Request[Either[OpenAIException, ListMessageFilesResponse]] = {
+    val uri = OpenAIUris
+      .threadMessageFiles(threadId, messageId)
+      .withParams(queryParameters.toMap)
+
+    betaOpenAIAuthRequest
+      .get(uri)
+      .response(asJsonSnake[ListMessageFilesResponse])
+  }
+
+  /** Retrieve a message.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/getMessage]]
+    *
+    * @param threadId
+    *   The ID of the thread to which this message belongs.
+    *
+    * @param messageId
+    *   The ID of the message to retrieve.
+    */
+  def retrieveThreadMessage(
+      threadId: String,
+      messageId: String
+  ): Request[Either[OpenAIException, MessageData]] =
+    betaOpenAIAuthRequest
+      .get(OpenAIUris.threadMessage(threadId, messageId))
+      .response(asJsonSnake[MessageData])
+
+  /** Retrieves a message file.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/getMessageFile]]
+    *
+    * @param threadId
+    *   The ID of the thread to which the message and File belong.
+    *
+    * @param messageId
+    *   The ID of the message the file belongs to.
+    *
+    * @param fileId
+    *   The ID of the file being retrieved.
+    */
+  def retrieveThreadMessageFile(
+      threadId: String,
+      messageId: String,
+      fileId: String
+  ): Request[Either[OpenAIException, MessageFileData]] =
+    betaOpenAIAuthRequest
+      .get(OpenAIUris.threadMessageFile(threadId, messageId, fileId))
+      .response(asJsonSnake[MessageFileData])
+
+  /** Modifies a message.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/modifyMessage]]
+    *
+    * @param threadId
+    *   The ID of the thread to which this message belongs.
+    *
+    * @param messageId
+    *   The ID of the message to modify.
+    */
+  def modifyMessage(threadId: String, messageId: String, metadata: Map[String, String]): Request[Either[OpenAIException, MessageData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.threadMessage(threadId, messageId))
+      .body(metadata)
+      .response(asJsonSnake[MessageData])
+
+  /** Create an assistant with a model and instructions.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/createAssistant]]
+    *
+    * @param createAssistantBody
+    *   Create completion request body.
+    */
+  def createAssistant(createAssistantBody: CreateAssistantBody): Request[Either[OpenAIException, AssistantData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.Assistants)
+      .body(createAssistantBody)
+      .response(asJsonSnake[AssistantData])
+
+  /** Create an assistant file by attaching a File to an assistant.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/createAssistantFile]]
+    *
+    * @param assistantId
+    *   The ID of the assistant for which to create a File.
+    *
+    * @param fileId
+    *   A File ID (with purpose="assistants") that the assistant should use. Useful for tools like retrieval and code_interpreter that can
+    *   access files..
+    */
+  def createAssistantFile(assistantId: String, fileId: String): Request[Either[OpenAIException, AssistantFileData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.assistantFiles(assistantId))
+      .body(CreateAssistantFileBody(fileId))
+      .response(asJsonSnake[AssistantFileData])
+
+  /** Returns a list of assistants.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/listAssistants]]
+    */
+  def listAssistants(
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): Request[Either[OpenAIException, ListAssistantsResponse]] = {
+    val uri = OpenAIUris.Assistants
+      .withParams(queryParameters.toMap)
+
+    betaOpenAIAuthRequest
+      .get(uri)
+      .response(asJsonSnake[ListAssistantsResponse])
+  }
+
+  /** Returns a list of assistant files.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/listAssistantFiles]]
+    *
+    * @param assistantId
+    *   The ID of the assistant the file belongs to.
+    */
+  def listAssistantFiles(
+      assistantId: String,
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): Request[Either[OpenAIException, ListAssistantFilesResponse]] = {
+    val uri = OpenAIUris
+      .assistantFiles(assistantId)
+      .withParams(queryParameters.toMap)
+
+    betaOpenAIAuthRequest
+      .get(uri)
+      .response(asJsonSnake[ListAssistantFilesResponse])
+  }
+
+  /** Retrieves an assistant.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/getAssistant]]
+    *
+    * @param assistantId
+    *   The ID of the assistant to retrieve.
+    */
+  def retrieveAssistant(assistantId: String): Request[Either[OpenAIException, AssistantData]] =
+    betaOpenAIAuthRequest
+      .get(OpenAIUris.assistant(assistantId))
+      .response(asJsonSnake[AssistantData])
+
+  /** Retrieves an AssistantFile.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/getAssistantFile]]
+    *
+    * @param assistantId
+    *   The ID of the assistant who the file belongs to.
+    *
+    * @param fileId
+    *   The ID of the file we're getting.
+    */
+  def retrieveAssistantFile(assistantId: String, fileId: String): Request[Either[OpenAIException, AssistantFileData]] =
+    betaOpenAIAuthRequest
+      .get(OpenAIUris.assistantFile(assistantId, fileId))
+      .response(asJsonSnake[AssistantFileData])
+
+  /** Modifies an assistant.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/modifyAssistant]]
+    *
+    * @param assistantId
+    *   The ID of the assistant to modify.
+    *
+    * @param modifyAssistantBody
+    *   Modify assistant request body.
+    */
+  def modifyAssistant(assistantId: String, modifyAssistantBody: ModifyAssistantBody): Request[Either[OpenAIException, AssistantData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.assistant(assistantId))
+      .body(modifyAssistantBody)
+      .response(asJsonSnake[AssistantData])
+
+  /** Delete an assistant.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/deleteAssistant]]
+    *
+    * @param assistantId
+    *   The ID of the assistant to delete.
+    */
+  def deleteAssistant(assistantId: String): Request[Either[OpenAIException, DeleteAssistantResponse]] =
+    betaOpenAIAuthRequest
+      .delete(OpenAIUris.assistant(assistantId))
+      .response(asJsonSnake[DeleteAssistantResponse])
+
+  /** Delete an assistant file.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/deleteAssistantFile]]
+    *
+    * @param assistantId
+    *   The ID of the assistant that the file belongs to.
+    *
+    * @param fileId
+    *   The ID of the file to delete.
+    */
+  def deleteAssistantFile(assistantId: String, fileId: String): Request[Either[OpenAIException, DeleteAssistantFileResponse]] =
+    betaOpenAIAuthRequest
+      .delete(OpenAIUris.assistantFile(assistantId, fileId))
+      .response(asJsonSnake[DeleteAssistantFileResponse])
+
+  /** Create a run.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/createRun]]
+    *
+    * @param threadId
+    *   The ID of the thread to run.
+    * @param createRun
+    *   Create run request body.
+    */
+  def createRun(threadId: String, createRun: CreateRun): Request[Either[OpenAIException, RunData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.threadRuns(threadId))
+      .body(createRun)
+      .response(asJsonSnake[RunData])
+
+  /** Create a thread and run it in one request.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/createThreadAndRun]]
+    *
+    * @param createThreadAndRun
+    *   Create thread and run request body.
+    */
+  def createThreadAndRun(createThreadAndRun: CreateThreadAndRun): Request[Either[OpenAIException, RunData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.ThreadsRuns)
+      .body(createThreadAndRun)
+      .response(asJsonSnake[RunData])
+
+  /** Returns a list of runs belonging to a thread..
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/listRuns]]
+    *
+    * @param threadId
+    *   The ID of the thread the run belongs to.
+    */
+  def listRuns(threadId: String): Request[Either[OpenAIException, ListRunsResponse]] =
+    betaOpenAIAuthRequest
+      .get(OpenAIUris.threadRuns(threadId))
+      .response(asJsonSnake[ListRunsResponse])
+
+  /** Returns a list of run steps belonging to a run.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/listRunSteps]]
+    *
+    * @param threadId
+    *   The ID of the thread the run and run steps belong to.
+    *
+    * @param runId
+    *   The ID of the run the run steps belong to.
+    */
+  def listRunSteps(
+      threadId: String,
+      runId: String,
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): Request[Either[OpenAIException, ListRunStepsResponse]] = {
+    val uri = OpenAIUris
+      .threadRunSteps(threadId, runId)
+      .withParams(queryParameters.toMap)
+
+    betaOpenAIAuthRequest
+      .get(uri)
+      .response(asJsonSnake[ListRunStepsResponse])
+  }
+
+  /** Retrieves a run.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/getRun]]
+    *
+    * @param threadId
+    *   The ID of the thread that was run.
+    *
+    * @param runId
+    *   The ID of the run to retrieve.
+    */
+  def retrieveRun(threadId: String, runId: String): Request[Either[OpenAIException, RunData]] =
+    betaOpenAIAuthRequest
+      .get(OpenAIUris.threadRun(threadId, runId))
+      .response(asJsonSnake[RunData])
+
+  /** Retrieves a run step.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/getRunStep]]
+    *
+    * @param threadId
+    *   The ID of the thread to which the run and run step belongs.
+    *
+    * @param runId
+    *   The ID of the run to which the run step belongs.
+    *
+    * @param stepId
+    *   The ID of the run step to retrieve.
+    */
+  def retrieveRunStep(threadId: String, runId: String, stepId: String): Request[Either[OpenAIException, RunStepData]] =
+    betaOpenAIAuthRequest
+      .get(OpenAIUris.threadRunStep(threadId, runId, stepId))
+      .response(asJsonSnake[RunStepData])
+
+  /** Modifies a run.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/modifyRun]]
+    *
+    * @param threadId
+    *   The ID of the thread that was run.
+    *
+    * @param runId
+    *   The ID of the run to modify.
+    */
+  def modifyRun(threadId: String, runId: String, metadata: Map[String, String]): Request[Either[OpenAIException, RunData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.threadRun(threadId, runId))
+      .body(ModifyRun(metadata))
+      .response(asJsonSnake[RunData])
+
+  /** When a run has the status: "requires_action" and required_action.type is submit_tool_outputs, this endpoint can be used to submit the
+    * outputs from the tool calls once they're all completed. All outputs must be submitted in a single request.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/submitToolOutputs]]
+    *
+    * @param threadId
+    *   The ID of the thread to which this run belongs.
+    * @param runId
+    *   The ID of the run that requires the tool output submission.
+    * @param toolOutputs
+    *   A list of tools for which the outputs are being submitted.
+    */
+  def submitToolOutputs(threadId: String, runId: String, toolOutputs: Seq[ToolOutput]): Request[Either[OpenAIException, RunData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.threadRunSubmitToolOutputs(threadId, runId))
+      .body(SubmitToolOutputsToRun(toolOutputs))
+      .response(asJsonSnake[RunData])
+//
+  /** Cancels a run that is in_progress.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/cancelRun]]
+    *
+    * @param threadId
+    *   The ID of the thread to which this run belongs.
+    *
+    * @param runId
+    *   The ID of the run to cancel.
+    */
+  def cancelRun(threadId: String, runId: String): Request[Either[OpenAIException, RunData]] =
+    betaOpenAIAuthRequest
+      .post(OpenAIUris.threadRunCancel(threadId, runId))
+      .response(asJsonSnake[RunData])
+
+  protected val openAIAuthRequest: PartialRequest[Either[String, String]] = basicRequest.auth
     .bearer(authToken)
+
+  protected val betaOpenAIAuthRequest: PartialRequest[Either[String, String]] =
+    openAIAuthRequest.withHeaders(openAIAuthRequest.headers :+ Header("OpenAI-Beta", "assistants=v1"))
+
 }
 
 private object OpenAIUris {
@@ -625,6 +1100,10 @@ private object OpenAIUris {
   val Translations: Uri = AudioBase.addPath("translations")
   val VariationsImage: Uri = ImageBase.addPath("variations")
 
+  val Assistants: Uri = uri"https://api.openai.com/v1/assistants"
+  val Threads: Uri = uri"https://api.openai.com/v1/threads"
+  val ThreadsRuns: Uri = uri"https://api.openai.com/v1/threads/runs"
+
   def cancelFineTune(fineTuneId: String): Uri = FineTunes.addPath(fineTuneId, "cancel")
   def file(fileId: String): Uri = Files.addPath(fileId)
   def fileContent(fileId: String): Uri = Files.addPath(fileId, "content")
@@ -632,4 +1111,32 @@ private object OpenAIUris {
   def fineTuneEvents(fineTuneId: String): Uri = FineTunes.addPath(fineTuneId, "events")
   def fineTune(fineTuneId: String): Uri = FineTunes.addPath(fineTuneId)
   def model(modelId: String): Uri = Models.addPath(modelId)
+
+  def assistant(assistantId: String): Uri = Assistants.addPath(assistantId)
+  def assistantFiles(assistantId: String): Uri = Assistants.addPath(assistantId).addPath("files")
+  def assistantFile(assistantId: String, fileId: String): Uri = Assistants.addPath(assistantId).addPath("files").addPath(fileId)
+
+  def thread(threadId: String): Uri = Threads.addPath(threadId)
+
+  def threadMessages(threadId: String): Uri = Threads.addPath(threadId).addPath("messages")
+  def threadMessage(threadId: String, messageId: String): Uri = Threads.addPath(threadId).addPath("messages").addPath(messageId)
+
+  def threadMessageFiles(threadId: String, messageId: String): Uri =
+    Threads.addPath(threadId).addPath("messages", messageId, "files")
+  def threadMessageFile(threadId: String, messageId: String, fileId: String): Uri =
+    Threads.addPath(threadId).addPath("messages", messageId, "files", fileId)
+
+  def threadRuns(threadId: String): Uri = Threads.addPath(threadId, "runs")
+  def threadRun(threadId: String, runId: String): Uri = Threads.addPath(threadId, "runs", runId)
+
+  def threadRunSteps(threadId: String, runId: String): Uri =
+    Threads.addPath(threadId, "runs", runId, "steps")
+  def threadRunStep(threadId: String, runId: String, stepId: String): Uri =
+    Threads.addPath(threadId, "runs", runId, "steps", stepId)
+  def threadRunCancel(threadId: String, runId: String): Uri =
+    Threads.addPath(threadId, "runs", runId, "cancel")
+
+  def threadRunSubmitToolOutputs(threadId: String, runId: String): Uri =
+    Threads.addPath(threadId, "runs", runId, "submit_tool_outputs")
+
 }
diff --git a/core/src/main/scala/sttp/openai/OpenAISyncClient.scala b/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
index be41448..c1ae11a 100644
--- a/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
+++ b/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
@@ -2,6 +2,15 @@ package sttp.openai
 
 import sttp.client4.{DefaultSyncBackend, Request, SyncBackend}
 import sttp.openai.OpenAIExceptions.OpenAIException
+import sttp.openai.requests.assistants.AssistantsRequestBody.{CreateAssistantBody, ModifyAssistantBody}
+import sttp.openai.requests.assistants.AssistantsResponseData.{
+  AssistantData,
+  AssistantFileData,
+  DeleteAssistantFileResponse,
+  DeleteAssistantResponse,
+  ListAssistantFilesResponse,
+  ListAssistantsResponse
+}
 import sttp.openai.requests.audio.AudioResponseData.AudioResponse
 import sttp.openai.requests.audio.RecognitionModel
 import sttp.openai.requests.audio.transcriptions.TranscriptionConfig
@@ -29,6 +38,18 @@ import sttp.openai.requests.images.variations.ImageVariationsConfig
 import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
 import sttp.openai.requests.moderations.ModerationsRequestBody.ModerationsBody
 import sttp.openai.requests.moderations.ModerationsResponseData.ModerationData
+import sttp.openai.requests.threads.ThreadsRequestBody.CreateThreadBody
+import sttp.openai.requests.threads.ThreadsResponseData.{DeleteThreadResponse, ThreadData}
+import sttp.openai.requests.threads.messages.ThreadMessagesRequestBody.CreateMessage
+import sttp.openai.requests.threads.messages.ThreadMessagesResponseData.{
+  ListMessageFilesResponse,
+  ListMessagesResponse,
+  MessageData,
+  MessageFileData
+}
+import sttp.openai.requests.threads.QueryParameters
+import sttp.openai.requests.threads.runs.ThreadRunsRequestBody.{CreateRun, CreateThreadAndRun, ToolOutput}
+import sttp.openai.requests.threads.runs.ThreadRunsResponseData.{ListRunStepsResponse, ListRunsResponse, RunData, RunStepData}
 
 import java.io.File
 
@@ -410,6 +431,358 @@ class OpenAISyncClient private (authToken: String, backend: SyncBackend, closeCl
   def getFineTuneEvents(fineTuneId: String): FineTuneEventsResponse =
     sendOrThrow(openAI.getFineTuneEvents(fineTuneId))
 
+  /** Create a thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/threads/createThread]]
+    *
+    * @param createThreadBody
+    *   Create completion request body.
+    */
+  def createThread(createThreadBody: CreateThreadBody): ThreadData =
+    sendOrThrow(openAI.createThread(createThreadBody))
+
+  /** Retrieves a thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/threads/getThread]]
+    *
+    * @param threadId
+    *   The ID of the thread to retrieve.
+    */
+  def retrieveThread(threadId: String): ThreadData =
+    sendOrThrow(openAI.retrieveThread(threadId))
+
+  /** Modifies a thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/threads/modifyThread]]
+    *
+    * @param threadId
+    *   The ID of the thread to modify. Only the metadata can be modified.
+    */
+  def modifyThread(threadId: String, metadata: Map[String, String]): ThreadData =
+    sendOrThrow(openAI.modifyThread(threadId, metadata))
+
+  /** Delete a thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/threads/deleteThread]]
+    *
+    * @param threadId
+    *   The ID of the thread to delete.
+    */
+  def deleteThread(threadId: String): DeleteThreadResponse =
+    sendOrThrow(openAI.deleteThread(threadId))
+
+  /** Create a message.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/createMessage]]
+    *
+    * @param threadId
+    *   The ID of the thread to create a message for.
+    */
+  def createThreadMessage(threadId: String, message: CreateMessage): MessageData =
+    sendOrThrow(openAI.createThreadMessage(threadId, message))
+
+  /** Returns a list of messages for a given thread.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/listMessages]]
+    *
+    * @param threadId
+    *   The ID of the thread the messages belong to.
+    */
+  def listThreadMessages(
+      threadId: String,
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): ListMessagesResponse =
+    sendOrThrow(openAI.listThreadMessages(threadId, queryParameters))
+
+  /** Returns a list of message files.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/listMessageFiles]]
+    *
+    * @param threadId
+    *   The ID of the thread that the message and files belong to.
+    *
+    * @param messageId
+    *   The ID of the message that the files belongs to.
+    */
+  def listThreadMessageFiles(
+      threadId: String,
+      messageId: String,
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): ListMessageFilesResponse =
+    sendOrThrow(openAI.listThreadMessageFiles(threadId, messageId, queryParameters))
+
+  /** Retrieve a message.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/getMessage]]
+    *
+    * @param threadId
+    *   The ID of the thread to which this message belongs.
+    *
+    * @param messageId
+    *   The ID of the message to retrieve.
+    */
+  def retrieveThreadMessage(
+      threadId: String,
+      messageId: String
+  ): MessageData =
+    sendOrThrow(openAI.retrieveThreadMessage(threadId, messageId))
+
+  /** Retrieves a message file.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/getMessageFile]]
+    *
+    * @param threadId
+    *   The ID of the thread to which the message and File belong.
+    *
+    * @param messageId
+    *   The ID of the message the file belongs to.
+    *
+    * @param fileId
+    *   The ID of the file being retrieved.
+    */
+  def retrieveThreadMessageFile(
+      threadId: String,
+      messageId: String,
+      fileId: String
+  ): MessageFileData =
+    sendOrThrow(openAI.retrieveThreadMessageFile(threadId, messageId, fileId))
+
+  /** Modifies a message.
+    *
+    * [[https://platform.openai.com/docs/api-reference/messages/modifyMessage]]
+    *
+    * @param threadId
+    *   The ID of the thread to which this message belongs.
+    *
+    * @param messageId
+    *   The ID of the message to modify.
+    */
+  def modifyMessage(threadId: String, messageId: String, metadata: Map[String, String]): MessageData =
+    sendOrThrow(openAI.modifyMessage(threadId, messageId, metadata))
+
+  /** Create an assistant with a model and instructions.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/createAssistant]]
+    *
+    * @param createAssistantBody
+    *   Create completion request body.
+    */
+  def createAssistant(createAssistantBody: CreateAssistantBody): AssistantData =
+    sendOrThrow(openAI.createAssistant(createAssistantBody))
+
+  /** Create an assistant file by attaching a File to an assistant.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/createAssistantFile]]
+    *
+    * @param assistantId
+    *   The ID of the assistant for which to create a File.
+    *
+    * @param fileId
+    *   A File ID (with purpose="assistants") that the assistant should use. Useful for tools like retrieval and code_interpreter that can
+    *   access files..
+    */
+  def createAssistantFile(assistantId: String, fileId: String): AssistantFileData =
+    sendOrThrow(openAI.createAssistantFile(assistantId, fileId))
+
+  /** Returns a list of assistants.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/listAssistants]]
+    */
+  def listAssistants(
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): ListAssistantsResponse =
+    sendOrThrow(openAI.listAssistants(queryParameters))
+
+  /** Returns a list of assistant files.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/listAssistantFiles]]
+    *
+    * @param assistantId
+    *   The ID of the assistant the file belongs to.
+    */
+  def listAssistantFiles(
+      assistantId: String,
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): ListAssistantFilesResponse =
+    sendOrThrow(openAI.listAssistantFiles(assistantId, queryParameters))
+
+  /** Retrieves an assistant.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/getAssistant]]
+    *
+    * @param assistantId
+    *   The ID of the assistant to retrieve.
+    */
+  def retrieveAssistant(assistantId: String): AssistantData =
+    sendOrThrow(openAI.retrieveAssistant(assistantId))
+
+  /** Retrieves an AssistantFile.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/getAssistantFile]]
+    *
+    * @param assistantId
+    *   The ID of the assistant who the file belongs to.
+    *
+    * @param fileId
+    *   The ID of the file we're getting.
+    */
+  def retrieveAssistantFile(assistantId: String, fileId: String): AssistantFileData =
+    sendOrThrow(openAI.retrieveAssistantFile(assistantId, fileId))
+
+  /** Modifies an assistant.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/modifyAssistant]]
+    *
+    * @param assistantId
+    *   The ID of the assistant to modify.
+    *
+    * @param modifyAssistantBody
+    */
+  def modifyAssistant(assistantId: String, modifyAssistantBody: ModifyAssistantBody): AssistantData =
+    sendOrThrow(openAI.modifyAssistant(assistantId, modifyAssistantBody))
+
+  /** Delete an assistant.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/deleteAssistant]]
+    *
+    * @param assistantId
+    *   The ID of the assistant to delete.
+    */
+  def deleteAssistant(assistantId: String): DeleteAssistantResponse =
+    sendOrThrow(openAI.deleteAssistant(assistantId))
+
+  /** Delete an assistant file.
+    *
+    * [[https://platform.openai.com/docs/api-reference/assistants/deleteAssistantFile]]
+    *
+    * @param assistantId
+    *   The ID of the assistant that the file belongs to.
+    *
+    * @param fileId
+    *   The ID of the file to delete.
+    */
+  def deleteAssistantFile(assistantId: String, fileId: String): DeleteAssistantFileResponse =
+    sendOrThrow(openAI.deleteAssistantFile(assistantId, fileId))
+
+  /** Create a run.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/createRun]]
+    *
+    * @param threadId
+    *   The ID of the thread to run.
+    * @param createRun
+    *   Create run request body.
+    */
+  def createRun(threadId: String, createRun: CreateRun): RunData =
+    sendOrThrow(openAI.createRun(threadId, createRun))
+
+  /** Create a thread and run it in one request.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/createThreadAndRun]]
+    *
+    * @param createThreadAndRun
+    *   Create thread and run request body.
+    */
+  def createThreadAndRun(createThreadAndRun: CreateThreadAndRun): RunData =
+    sendOrThrow(openAI.createThreadAndRun(createThreadAndRun))
+
+  /** Returns a list of runs belonging to a thread..
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/listRuns]]
+    *
+    * @param threadId
+    *   The ID of the thread the run belongs to.
+    */
+  def listRuns(threadId: String): ListRunsResponse =
+    sendOrThrow(openAI.listRuns(threadId))
+
+  /** Returns a list of run steps belonging to a run.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/listRunSteps]]
+    *
+    * @param threadId
+    *   The ID of the thread the run and run steps belong to.
+    *
+    * @param runId
+    *   The ID of the run the run steps belong to.
+    */
+  def listRunSteps(
+      threadId: String,
+      runId: String,
+      queryParameters: QueryParameters = QueryParameters.empty
+  ): ListRunStepsResponse =
+    sendOrThrow(openAI.listRunSteps(threadId, runId, queryParameters))
+
+  /** Retrieves a run.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/getRun]]
+    *
+    * @param threadId
+    *   The ID of the thread that was run.
+    *
+    * @param runId
+    *   The ID of the run to retrieve.
+    */
+  def retrieveRun(threadId: String, runId: String): RunData =
+    sendOrThrow(openAI.retrieveRun(threadId, runId))
+
+  /** Retrieves a run step.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/getRunStep]]
+    *
+    * @param threadId
+    *   The ID of the thread to which the run and run step belongs.
+    *
+    * @param runId
+    *   The ID of the run to which the run step belongs.
+    *
+    * @param stepId
+    *   The ID of the run step to retrieve.
+    */
+  def retrieveRunStep(threadId: String, runId: String, stepId: String): RunStepData =
+    sendOrThrow(openAI.retrieveRunStep(threadId, runId, stepId))
+
+  /** Modifies a run.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/modifyRun]]
+    *
+    * @param threadId
+    *   The ID of the thread that was run.
+    *
+    * @param runId
+    *   The ID of the run to modify.
+    */
+  def modifyRun(threadId: String, runId: String, metadata: Map[String, String]): RunData =
+    sendOrThrow(openAI.modifyRun(threadId, runId, metadata))
+
+  /** When a run has the status: "requires_action" and required_action.type is submit_tool_outputs, this endpoint can be used to submit the
+    * outputs from the tool calls once they're all completed. All outputs must be submitted in a single request.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/submitToolOutputs]]
+    *
+    * @param threadId
+    *   The ID of the thread to which this run belongs.
+    * @param runId
+    *   The ID of the run that requires the tool output submission.
+    * @param toolOutputs
+    *   A list of tools for which the outputs are being submitted.
+    */
+  def submitToolOutputs(threadId: String, runId: String, toolOutputs: Seq[ToolOutput]): RunData =
+    sendOrThrow(openAI.submitToolOutputs(threadId, runId, toolOutputs))
+  //
+  /** Cancels a run that is in_progress.
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/cancelRun]]
+    *
+    * @param threadId
+    *   The ID of the thread to which this run belongs.
+    *
+    * @param runId
+    *   The ID of the run to cancel.
+    */
+  def cancelRun(threadId: String, runId: String): RunData =
+    sendOrThrow(openAI.cancelRun(threadId, runId))
+
   /** Closes and releases resources of http client if was not provided explicitly, otherwise works no-op.
     */
   def close(): Unit = if (closeClient) backend.close() else ()
diff --git a/core/src/main/scala/sttp/openai/requests/assistants/AssistantsRequestBody.scala b/core/src/main/scala/sttp/openai/requests/assistants/AssistantsRequestBody.scala
new file mode 100644
index 0000000..5855097
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/assistants/AssistantsRequestBody.scala
@@ -0,0 +1,101 @@
+package sttp.openai.requests.assistants
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.chat.message.Tool
+
+object AssistantsRequestBody {
+
+  /** @param model
+    *   ID of the model to use. You can use the List models API to see all of your available models, or see our Model overview for
+    *   descriptions of them.
+    *
+    * @param name
+    *   The name of the assistant. The maximum length is 256 characters.
+    *
+    * @param description
+    *   The description of the assistant. The maximum length is 512 characters.
+    *
+    * @param instructions
+    *   The system instructions that the assistant uses. The maximum length is 32768 characters.
+    *
+    * @param tools
+    *   A list of tool enabled on the assistant. There can be a maximum of 128 tools per assistant. Tools can be of types code_interpreter,
+    *   retrieval, or function.
+    *
+    * @param file_ids
+    *   A list of file IDs attached to this assistant. There can be a maximum of 20 files attached to the assistant. Files are ordered by
+    *   their creation date in ascending order.
+    *
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/assistants/createAssistant]]
+    */
+  case class CreateAssistantBody(
+      model: String,
+      name: Option[String] = None,
+      description: Option[String] = None,
+      instructions: Option[String] = None,
+      tools: Seq[Tool] = Seq.empty,
+      file_ids: Seq[String] = Seq.empty,
+      metadata: Option[Map[String, String]] = None
+  )
+  object CreateAssistantBody {
+    implicit val createAssistantBodyW: SnakePickle.Writer[CreateAssistantBody] = SnakePickle.macroW[CreateAssistantBody]
+  }
+
+  /** @param fileId
+    *   A File ID (with purpose="assistants") that the assistant should use. Useful for tools like retrieval and code_interpreter that can
+    *   access files.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/assistants/createAssistantFile]]
+    */
+  case class CreateAssistantFileBody(fileId: String)
+
+  object CreateAssistantFileBody {
+    implicit val createAssistantFileBodyW: SnakePickle.Writer[CreateAssistantFileBody] = SnakePickle.macroW[CreateAssistantFileBody]
+  }
+
+  /** @param model
+    *   ID of the model to use. You can use the List models API to see all of your available models, or see our Model overview for
+    *   descriptions of them.
+    *
+    * @param name
+    *   The name of the assistant. The maximum length is 256 characters.
+    *
+    * @param description
+    *   The description of the assistant. The maximum length is 512 characters.
+    *
+    * @param instructions
+    *   The system instructions that the assistant uses. The maximum length is 32768 characters.
+    *
+    * @param tools
+    *   A list of tool enabled on the assistant. There can be a maximum of 128 tools per assistant. Tools can be of types code_interpreter,
+    *   retrieval, or function.
+    *
+    * @param fileIds
+    *   A list of File IDs attached to this assistant. There can be a maximum of 20 files attached to the assistant. Files are ordered by
+    *   their creation date in ascending order. If a file was previously attached to the list but does not show up in the list, it will be
+    *   deleted from the assistant.
+    *
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/assistants/modifyAssistant]]
+    */
+  case class ModifyAssistantBody(
+      model: Option[String] = None,
+      name: Option[String] = None,
+      description: Option[String] = None,
+      instructions: Option[String] = None,
+      tools: Seq[Tool] = Seq.empty,
+      fileIds: Seq[String] = Seq.empty,
+      metadata: Map[String, String] = Map.empty
+  )
+
+  object ModifyAssistantBody {
+    implicit val modifyAssistantBodyW: SnakePickle.Writer[ModifyAssistantBody] = SnakePickle.macroW[ModifyAssistantBody]
+  }
+}
diff --git a/core/src/main/scala/sttp/openai/requests/assistants/AssistantsResponseData.scala b/core/src/main/scala/sttp/openai/requests/assistants/AssistantsResponseData.scala
new file mode 100644
index 0000000..d5e243e
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/assistants/AssistantsResponseData.scala
@@ -0,0 +1,164 @@
+package sttp.openai.requests.assistants
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.chat.message.Tool
+
+object AssistantsResponseData {
+
+  /** Represents an assistant that can call the model and use tools.
+    * @param id
+    *   The identifier, which can be referenced in API endpoints.
+    *
+    * @param object
+    *   The object type, which is always assistant.
+    *
+    * @param createdAt
+    *   The Unix timestamp (in seconds) for when the assistant was created.
+    *
+    * @param name
+    *   The name of the assistant. The maximum length is 256 characters.
+    *
+    * @param description
+    *   The description of the assistant. The maximum length is 512 characters.
+    *
+    * @param model
+    *   ID of the model to use. You can use the List models API to see all of your available models, or see our Model overview for
+    *   descriptions of them.
+    *
+    * @param instructions
+    *   The system instructions that the assistant uses. The maximum length is 32768 characters.
+    *
+    * @param tools
+    *   A list of tool enabled on the assistant. There can be a maximum of 128 tools per assistant. Tools can be of types code_interpreter,
+    *   retrieval, or function.
+    *
+    * @param fileIds
+    *   A list of file IDs attached to this assistant. There can be a maximum of 20 files attached to the assistant. Files are ordered by
+    *   their creation date in ascending order.
+    *
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/assistants/object]]
+    */
+  case class AssistantData(
+      id: String,
+      `object`: String,
+      createdAt: Int,
+      name: Option[String],
+      description: Option[String],
+      model: String,
+      instructions: Option[String],
+      tools: Seq[Tool],
+      fileIds: Seq[String],
+      metadata: Map[String, String]
+  )
+
+  object AssistantData {
+    implicit val assistantDataR: SnakePickle.Reader[AssistantData] = SnakePickle.macroR[AssistantData]
+  }
+
+  /** @param id
+    *   The identifier, which can be referenced in API endpoints.
+    *
+    * @param object
+    *   The object type, which is always assistant.file.
+    *
+    * @param createdAt
+    *   The Unix timestamp (in seconds) for when the assistant file was created.
+    *
+    * @param assistantId
+    *   The assistant ID that the file is attached to.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/assistants/file-object]]
+    */
+
+  case class AssistantFileData(
+      id: String,
+      `object`: String,
+      createdAt: Int,
+      assistantId: String
+  )
+
+  object AssistantFileData {
+    implicit val assistantFileDataR: SnakePickle.Reader[AssistantFileData] = SnakePickle.macroR[AssistantFileData]
+  }
+
+  /** @param object
+    *   Always "list"
+    * @param data
+    *   A list of assistant objects.
+    * @param firstId
+    * @param lastId
+    * @param hasMore
+    *   }
+    */
+  case class ListAssistantsResponse(
+      `object`: String = "list",
+      data: Seq[AssistantData],
+      firstId: String,
+      lastId: String,
+      hasMore: Boolean
+  )
+  object ListAssistantsResponse {
+    implicit val listAssistantsResponseR: SnakePickle.Reader[ListAssistantsResponse] = SnakePickle.macroR[ListAssistantsResponse]
+  }
+
+  /** @param object
+    *   Always "list"
+    * @param data
+    *   A list of assistant objects.
+    * @param firstId
+    * @param lastId
+    * @param hasMore
+    *   }
+    */
+  case class ListAssistantFilesResponse(
+      `object`: String = "list",
+      data: Seq[AssistantFileData],
+      firstId: String,
+      lastId: String,
+      hasMore: Boolean
+  )
+  object ListAssistantFilesResponse {
+    implicit val listAssistantFilesResponseR: SnakePickle.Reader[ListAssistantFilesResponse] =
+      SnakePickle.macroR[ListAssistantFilesResponse]
+  }
+
+  /** @param id
+    * @param `object`
+    *   assistant.deleted
+    * @param deleted
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/assistants/deleteAssistant]]
+    */
+  case class DeleteAssistantResponse(
+      id: String,
+      `object`: String,
+      deleted: Boolean
+  )
+
+  object DeleteAssistantResponse {
+    implicit val deleteAssistantResponseReadWriter: SnakePickle.ReadWriter[DeleteAssistantResponse] =
+      SnakePickle.macroRW[DeleteAssistantResponse]
+  }
+
+  /** @param id
+    * @param `object`
+    *   assistant.file.deleted
+    * @param deleted
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/assistants/deleteAssistantFile]]
+    */
+  case class DeleteAssistantFileResponse(
+      id: String,
+      `object`: String,
+      deleted: Boolean
+  )
+
+  object DeleteAssistantFileResponse {
+    implicit val deleteAssistantFileResponseReadWriter: SnakePickle.ReadWriter[DeleteAssistantFileResponse] =
+      SnakePickle.macroRW[DeleteAssistantFileResponse]
+  }
+}
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatChunkRequestResponseData.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatChunkRequestResponseData.scala
index 30199be..8af8332 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatChunkRequestResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatChunkRequestResponseData.scala
@@ -12,7 +12,12 @@ object ChatChunkRequestResponseData {
     * @param functionCall
     *   The name of the author of this message. May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters.
     */
-  case class Delta(role: Option[Role] = None, content: Option[String] = None, toolCalls: Seq[ToolCall] = Nil, functionCall: Option[FunctionCall] = None)
+  case class Delta(
+      role: Option[Role] = None,
+      content: Option[String] = None,
+      toolCalls: Seq[ToolCall] = Nil,
+      functionCall: Option[FunctionCall] = None
+  )
 
   object Delta {
     implicit val deltaR: SnakePickle.Reader[Delta] = SnakePickle.macroR[Delta]
@@ -34,7 +39,7 @@ object ChatChunkRequestResponseData {
       created: Int,
       model: String,
       `object`: String,
-      systemFingerprint: Option[String] = None,
+      systemFingerprint: Option[String] = None
   )
 
   object ChatChunkResponse {
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala
index 78502b8..d95cd22 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala
@@ -35,7 +35,7 @@ object ChatRequestResponseData {
       model: String,
       `object`: String,
       usage: Usage,
-      systemFingerprint: Option[String] = None,
+      systemFingerprint: Option[String] = None
   )
 
   object ChatResponse {
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
index f44b4a0..18c235a 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
@@ -23,12 +23,34 @@ object Tool {
       json => FunctionTool(json("description").str, json("name").str, json("parameters").obj.toMap)
     )
 
+  /** Code interpreter tool
+    *
+    *   The type of tool being defined: code_interpreter
+    */
+  case object CodeInterpreterTool extends Tool
+
+  /** Retrieval tool
+    *
+    *   The type of tool being defined: retrieval
+    */
+  case object RetrievalTool extends Tool
+
   implicit val toolRW: SnakePickle.ReadWriter[Tool] = SnakePickle
     .readwriter[Value]
     .bimap[Tool](
-      { case functionTool: FunctionTool =>
-        Obj("type" -> "function", "function" -> SnakePickle.writeJs(functionTool))
+      {
+        case functionTool: FunctionTool =>
+          Obj("type" -> "function", "function" -> SnakePickle.writeJs(functionTool))
+        case CodeInterpreterTool =>
+          Obj("type" -> "code_interpreter")
+        case RetrievalTool =>
+          Obj("type" -> "retrieval")
       },
-      json => SnakePickle.read[FunctionTool](json("function"))
+      json =>
+        json("type").str match {
+          case "function"         => SnakePickle.read[FunctionTool](json("function"))
+          case "code_interpreter" => CodeInterpreterTool
+          case "retrieval"        => RetrievalTool
+        }
     )
 }
diff --git a/core/src/main/scala/sttp/openai/requests/threads/QueryParameters.scala b/core/src/main/scala/sttp/openai/requests/threads/QueryParameters.scala
new file mode 100644
index 0000000..149160d
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/threads/QueryParameters.scala
@@ -0,0 +1,34 @@
+package sttp.openai.requests.threads
+
+/** @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.
+  * @param order
+  *   Defaults to desc Sort order by the created_at timestamp of the objects. asc for ascending order and desc for descending order.
+  * @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 before
+  *   A cursor for use in pagination. before 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 before=obj_foo in order to fetch the previous page of
+  *   the list.
+  */
+case class QueryParameters(
+    limit: Option[Int] = None,
+    order: Option[String] = None,
+    after: Option[String] = None,
+    before: Option[String] = None
+) {
+
+  def toMap: Map[String, String] = {
+    val queryParams = limit.map(_.toString).map("limit" -> _) ++
+      order.map("order" -> _) ++
+      after.map("after" -> _) ++
+      before.map("before" -> _)
+    queryParams.toMap
+  }
+}
+
+object QueryParameters {
+  val empty: QueryParameters = QueryParameters(None, None, None, None)
+}
\ No newline at end of file
diff --git a/core/src/main/scala/sttp/openai/requests/threads/ThreadsRequestBody.scala b/core/src/main/scala/sttp/openai/requests/threads/ThreadsRequestBody.scala
new file mode 100644
index 0000000..386a167
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/threads/ThreadsRequestBody.scala
@@ -0,0 +1,25 @@
+package sttp.openai.requests.threads
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.threads.messages.ThreadMessagesRequestBody.CreateMessage
+
+object ThreadsRequestBody {
+
+  /** @param messages
+    *   A list of messages to start the thread with.
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/threads/createThread]]
+    */
+  case class CreateThreadBody(
+      messages: Option[Seq[CreateMessage]] = None,
+      metadata: Option[Map[String, String]] = None
+  )
+
+  object CreateThreadBody {
+    implicit val completionBodyW: SnakePickle.Writer[CreateThreadBody] = SnakePickle.macroW[CreateThreadBody]
+  }
+
+}
diff --git a/core/src/main/scala/sttp/openai/requests/threads/ThreadsResponseData.scala b/core/src/main/scala/sttp/openai/requests/threads/ThreadsResponseData.scala
new file mode 100644
index 0000000..b634993
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/threads/ThreadsResponseData.scala
@@ -0,0 +1,46 @@
+package sttp.openai.requests.threads
+
+import sttp.openai.json.SnakePickle
+
+object ThreadsResponseData {
+
+  /** @param id
+    *   string The identifier, which can be referenced in API endpoints.
+    *
+    * @param object
+    *   string The object type, which is always thread.
+    *
+    * @param createdAt
+    *   integer The Unix timestamp (in seconds) for when the thread was created.
+    *
+    * @param metadata
+    *   map Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the
+    *   object in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long. For
+    *   more information please visit: [[https://platform.openai.com/docs/api-reference/threads/object]]
+    */
+  case class ThreadData(
+      id: String,
+      `object`: String = "thread",
+      createdAt: Option[Int] = None,
+      metadata: Map[String, String] = Map.empty
+  )
+
+  object ThreadData {
+    implicit val threadDataR: SnakePickle.Reader[ThreadData] = SnakePickle.macroR[ThreadData]
+  }
+
+  /** @param id
+    * @param `object`
+    *   thread.deleted
+    * @param deleted
+    */
+  case class DeleteThreadResponse(
+      id: String,
+      `object`: String,
+      deleted: Boolean
+  )
+
+  object DeleteThreadResponse {
+    implicit val deleteThreadResponseReadWriter: SnakePickle.ReadWriter[DeleteThreadResponse] = SnakePickle.macroRW[DeleteThreadResponse]
+  }
+}
diff --git a/core/src/main/scala/sttp/openai/requests/threads/messages/ThreadMessagesRequestBody.scala b/core/src/main/scala/sttp/openai/requests/threads/messages/ThreadMessagesRequestBody.scala
new file mode 100644
index 0000000..d4daa77
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/threads/messages/ThreadMessagesRequestBody.scala
@@ -0,0 +1,31 @@
+package sttp.openai.requests.threads.messages
+
+import sttp.openai.json.SnakePickle
+
+object ThreadMessagesRequestBody {
+
+  /** @param role
+    *   string Required The role of the entity that is creating the message. Currently only user is supported.
+    * @param content
+    *   string Required The content of the message.
+    * @param file_ids
+    *   array Optional Defaults to [] A list of File IDs that the message should use. There can be a maximum of 10 files attached to a
+    *   message. Useful for tools like retrieval and code_interpreter that can access and use files.
+    * @param metadata
+    *   map Optional Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information
+    *   about the object in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters
+    *   long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/messages/createMessage]]
+    */
+  case class CreateMessage(
+      role: String,
+      content: String,
+      file_ids: Seq[String] = Seq.empty,
+      metadata: Option[Map[String, String]] = None
+  )
+
+  object CreateMessage {
+    implicit val completionBodyW: SnakePickle.Writer[CreateMessage] = SnakePickle.macroW[CreateMessage]
+  }
+}
diff --git a/core/src/main/scala/sttp/openai/requests/threads/messages/ThreadMessagesResponseData.scala b/core/src/main/scala/sttp/openai/requests/threads/messages/ThreadMessagesResponseData.scala
new file mode 100644
index 0000000..eedb893
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/threads/messages/ThreadMessagesResponseData.scala
@@ -0,0 +1,239 @@
+package sttp.openai.requests.threads.messages
+
+import sttp.openai.json.SnakePickle
+
+object ThreadMessagesResponseData {
+
+  /** @param id
+    *   The identifier, which can be referenced in API endpoints.
+    * @param object
+    *   The object type, which is always thread.message.
+    * @param createdAt
+    *   The Unix timestamp (in seconds) for when the message was created.
+    * @param threadId
+    *   The thread ID that this message belongs to.
+    * @param role
+    *   The entity that produced the message. One of user or assistant.
+    * @param content
+    *   The content of the message in array of text and/or images.
+    * @param assistantId
+    *   If applicable, the ID of the assistant that authored this message.
+    * @param runId
+    *   If applicable, the ID of the run associated with the authoring of this message.
+    * @param fileIds
+    *   A list of file IDs that the assistant should use. Useful for tools like retrieval and code_interpreter that can access files. A
+    *   maximum of 10 files can be attached to a message.
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/messages/object]]
+    */
+  case class MessageData(
+      id: String,
+      `object`: String = "thread.message",
+      createdAt: Int,
+      threadId: Option[String] = None,
+      role: String,
+      content: Seq[Content],
+      assistantId: Option[String] = None,
+      runId: Option[String] = None,
+      fileIds: Seq[String] = Seq.empty,
+      metadata: Map[String, String] = Map.empty
+  )
+
+  object MessageData {
+    implicit val messageDataR: SnakePickle.Reader[MessageData] = SnakePickle.macroR[MessageData]
+  }
+
+  /** @param object
+    *   Always "list"
+    * @param data
+    *   A list of message objects.
+    * @param firstId
+    * @param lastId
+    * @param hasMore
+    *   }
+    */
+  case class ListMessagesResponse(
+      `object`: String = "list",
+      data: Seq[MessageData],
+      firstId: String,
+      lastId: String,
+      hasMore: Boolean
+  )
+  object ListMessagesResponse {
+    implicit val listMessagesResponseR: SnakePickle.Reader[ListMessagesResponse] = SnakePickle.macroR[ListMessagesResponse]
+  }
+
+  /** @param id
+    *   The identifier, which can be referenced in API endpoints.
+    *
+    * @param object
+    *   The object type, which is always thread.message.file.
+    *
+    * @param createdAt
+    *   The Unix timestamp (in seconds) for when the message file was created.
+    *
+    * @param messageId
+    *   The ID of the message that the File is attached to.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/messages/file-object]]
+    */
+  case class MessageFileData(
+      id: String,
+      `object`: String,
+      createdAt: Int,
+      messageId: String
+  )
+
+  object MessageFileData {
+    implicit val messageFileDataR: SnakePickle.Reader[MessageFileData] = SnakePickle.macroR[MessageFileData]
+  }
+
+  /** @param object
+    *   Always "list"
+    * @param data
+    *   A list of message file objects.
+    * @param firstId
+    * @param lastId
+    * @param hasMore
+    *   }
+    */
+  case class ListMessageFilesResponse(
+      `object`: String = "list",
+      data: Seq[MessageFileData],
+      firstId: String,
+      lastId: String,
+      hasMore: Boolean
+  )
+  object ListMessageFilesResponse {
+    implicit val listMessageFilesResponseR: SnakePickle.Reader[ListMessageFilesResponse] = SnakePickle.macroR[ListMessageFilesResponse]
+  }
+
+  sealed trait Annotation
+
+  /** @param fileId
+    *   The ID of the specific File the citation is from.
+    *
+    * @param quote
+    *   The specific quote in the file.
+    */
+  case class FileCitation(
+      fileId: String,
+      quote: String
+  )
+
+  implicit val fileCitationR: SnakePickle.Reader[FileCitation] = SnakePickle.macroR[FileCitation]
+
+  /** A citation within the message that points to a specific quote from a specific File associated with the assistant or the message.
+    * Generated when the assistant uses the "retrieval" tool to search files.
+    * @param type
+    *   Always file_citation.
+    *
+    * @param text
+    *   The text in the message content that needs to be replaced.
+    *
+    * @param fileCitation
+    * @param startIndex
+    * @param endIndex
+    */
+  case class FileCitationAnnotation(
+      `type`: String,
+      text: String,
+      fileCitation: FileCitation,
+      startIndex: Int,
+      endIndex: Int
+  ) extends Annotation
+
+  implicit val fileCitationAnnotationR: SnakePickle.Reader[FileCitationAnnotation] = SnakePickle.macroR[FileCitationAnnotation]
+
+  /** @param fileId
+    *   The ID of the file that was generated.
+    */
+  case class FilePath(fileId: String)
+
+  implicit val filePathR: SnakePickle.Reader[FilePath] = SnakePickle.macroR[FilePath]
+
+  /** URL for the file that's generated when the assistant used the code_interpreter tool to generate a file.
+    * @param type
+    *   Always "file_path".
+    *
+    * @param text
+    *   The text in the message content that needs to be replaced.
+    *
+    * @param filePath
+    * @param startIndex
+    * @param endIndex
+    */
+  case class FilePathAnnotation(
+      `type`: String,
+      text: String,
+      filePath: FilePath,
+      startIndex: Int,
+      endIndex: Int
+  ) extends Annotation
+
+  implicit val filePathAnnotationR: SnakePickle.Reader[FilePathAnnotation] = SnakePickle.macroR[FilePathAnnotation]
+
+  implicit val annotationR: SnakePickle.Reader[Annotation] = SnakePickle
+    .reader[ujson.Value]
+    .map(json =>
+      json("type").str match {
+        case "file_citation" => SnakePickle.read[FileCitationAnnotation](json)
+        case "file_path"     => SnakePickle.read[FilePathAnnotation](json)
+      }
+    )
+
+  // should be sealed trait but there are problems with $type fields
+  trait Content
+
+  object Content {
+
+    /** @param value
+      *   The data that makes up the text
+      * @param annotations.
+      */
+    case class TextContentValue(value: String, annotations: Seq[Annotation])
+
+    implicit val textContentValueR: SnakePickle.Reader[TextContentValue] = SnakePickle.macroR[TextContentValue]
+
+    /** The text content that is part of a message
+      * @param `type`
+      *   Always text.
+      */
+    case class TextContent(`type`: String, text: TextContentValue) extends Content
+
+    implicit val textContentR: SnakePickle.Reader[TextContent] = SnakePickle.macroR[TextContent]
+
+    /** @param fileId
+      *   string The File ID of the image in the message content.
+      */
+    case class ImageFile(fileId: String)
+
+    implicit val imageFileR: SnakePickle.Reader[ImageFile] = SnakePickle.macroR[ImageFile]
+
+    /** References an image File in the content of a message
+      *
+      * @param type
+      *   Always image_file.
+      *
+      * @param imageFile
+      *   object
+      */
+    case class ImageFileContent(`type`: String, imageFile: ImageFile) extends Content
+
+    implicit val imageFileContentR: SnakePickle.Reader[ImageFileContent] = SnakePickle.macroR[ImageFileContent]
+
+    implicit val contentR: SnakePickle.Reader[Content] = SnakePickle
+      .reader[ujson.Value]
+      .map(json =>
+        json("type").str match {
+          case "text" =>
+            SnakePickle.read[TextContent](json)
+          case "image_file" => SnakePickle.read[ImageFileContent](json)
+        }
+      )
+  }
+
+}
diff --git a/core/src/main/scala/sttp/openai/requests/threads/runs/ThreadRunsRequestBody.scala b/core/src/main/scala/sttp/openai/requests/threads/runs/ThreadRunsRequestBody.scala
new file mode 100644
index 0000000..20fa81a
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/threads/runs/ThreadRunsRequestBody.scala
@@ -0,0 +1,116 @@
+package sttp.openai.requests.threads.runs
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.chat.message.Tool
+import sttp.openai.requests.threads.ThreadsRequestBody.CreateThreadBody
+
+object ThreadRunsRequestBody {
+
+  /** @param assistantId
+    *   The ID of the assistant to use to execute this run.
+    *
+    * @param model
+    *   The ID of the Model to be used to execute this run. If a value is provided here, it will override the model associated with the
+    *   assistant. If not, the model associated with the assistant will be used.
+    *
+    * @param instructions
+    *   Overrides the instructions of the assistant. This is useful for modifying the behavior on a per-run basis.
+    *
+    * @param additionalInstructions
+    *   Appends additional instructions at the end of the instructions for the run. This is useful for modifying the behavior on a per-run
+    *   basis without overriding other instructions.
+    *
+    * @param tools
+    *   Override the tools the assistant can use for this run. This is useful for modifying the behavior on a per-run basis.
+    *
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/runs/createRun]]
+    */
+  case class CreateRun(
+      assistantId: String,
+      model: Option[String] = None,
+      instructions: Option[String] = None,
+      additionalInstructions: Option[String] = None,
+      tools: Seq[Tool] = Seq.empty,
+      metadata: Map[String, String] = Map.empty
+  )
+
+  object CreateRun {
+    implicit val createRunW: SnakePickle.Writer[CreateRun] = SnakePickle.macroW[CreateRun]
+  }
+
+  /** @param assistantId
+    *   The ID of the assistant to use to execute this run.
+    *
+    * @param thread
+    *
+    * @param model
+    *   The ID of the Model to be used to execute this run. If a value is provided here, it will override the model associated with the
+    *   assistant. If not, the model associated with the assistant will be used.
+    *
+    * @param instructions
+    *   Overrides the instructions of the assistant. This is useful for modifying the behavior on a per-run basis.
+    *
+    * @param tools
+    *   Override the tools the assistant can use for this run. This is useful for modifying the behavior on a per-run basis.
+    *
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/runs/createThreadAndRun]]
+    */
+  case class CreateThreadAndRun(
+      assistantId: String,
+      thread: CreateThreadBody,
+      model: Option[String] = None,
+      instructions: Option[String] = None,
+      tools: Seq[Tool] = Seq.empty,
+      metadata: Map[String, String] = Map.empty
+  )
+
+  object CreateThreadAndRun {
+    implicit val createThreadAndRunW: SnakePickle.Writer[CreateThreadAndRun] = SnakePickle.macroW[CreateThreadAndRun]
+  }
+
+  /** @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/runs/modifyRun]]
+    */
+  case class ModifyRun(
+      metadata: Map[String, String] = Map.empty
+  )
+
+  object ModifyRun {
+    implicit val modifyRunW: SnakePickle.Writer[ModifyRun] = SnakePickle.macroW[ModifyRun]
+  }
+
+  /** @param toolOutputs
+    *   A list of tools for which the outputs are being submitted.
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/runs/submitToolOutputs]]
+    */
+  case class SubmitToolOutputsToRun(
+      toolOutputs: Seq[ToolOutput]
+  )
+  object SubmitToolOutputsToRun {
+    implicit val submitToolOutputsToRunW: SnakePickle.Writer[SubmitToolOutputsToRun] = SnakePickle.macroW[SubmitToolOutputsToRun]
+  }
+
+  /** @param toolCallId
+    *   The ID of the tool call in the required_action object within the run object the output is being submitted for.
+    *
+    * @param output
+    *   The output of the tool call to be submitted to continue the run.
+    */
+  case class ToolOutput(toolCallId: Option[String], output: String)
+
+  object ToolOutput {
+    implicit val toolOutputW: SnakePickle.Writer[ToolOutput] = SnakePickle.macroW[ToolOutput]
+  }
+}
diff --git a/core/src/main/scala/sttp/openai/requests/threads/runs/ThreadRunsResponseData.scala b/core/src/main/scala/sttp/openai/requests/threads/runs/ThreadRunsResponseData.scala
new file mode 100644
index 0000000..da49a2e
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/threads/runs/ThreadRunsResponseData.scala
@@ -0,0 +1,444 @@
+package sttp.openai.requests.threads.runs
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.Usage
+import sttp.openai.requests.completions.chat.message.Tool
+
+object ThreadRunsResponseData {
+
+  /** Represents an execution run on a thread
+    *
+    * @param id
+    *   The identifier, which can be referenced in API endpoints.
+    *
+    * @param object
+    *   The object type, which is always thread.run.
+    *
+    * @param createdAt
+    *   The Unix timestamp (in seconds) for when the run was created.
+    *
+    * @param threadId
+    *   The ID of the thread that was executed on as a part of this run.
+    *
+    * @param assistantId
+    *   The ID of the assistant used for execution of this run.
+    *
+    * @param status
+    *   The status of the run, which can be either queued, in_progress, requires_action, cancelling, cancelled, failed, completed, or
+    *   expired.
+    *
+    * @param requiredAction
+    *   Details on the action required to continue the run. Will be null if no action is required.
+    *
+    * @param lastError
+    *   The last error associated with this run. Will be null if there are no errors.
+    *
+    * @param expiresAt
+    *   The Unix timestamp (in seconds) for when the run will expire.
+    *
+    * @param startedAt
+    *   The Unix timestamp (in seconds) for when the run was started.
+    *
+    * @param cancelledAt
+    *   The Unix timestamp (in seconds) for when the run was cancelled.
+    *
+    * @param failedAt
+    *   The Unix timestamp (in seconds) for when the run failed.
+    *
+    * @param completedAt
+    *   The Unix timestamp (in seconds) for when the run was completed.
+    *
+    * @param model
+    *   The model that the assistant used for this run.
+    *
+    * @param instructions
+    *   The instructions that the assistant used for this run.
+    *
+    * @param tools
+    *   The list of tools that the assistant used for this run.
+    *
+    * @param fileIds
+    *   The list of File IDs the assistant used for this run.
+    *
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * @param usage
+    *   Usage statistics related to the run. This value will be null if the run is not in a terminal state (i.e. in_progress, queued, etc.).
+    *
+    * For more information please visit: [[https://platform.openai.com/docs/api-reference/runs/object]]
+    */
+  case class RunData(
+      id: String,
+      `object`: String,
+      createdAt: Int,
+      threadId: String,
+      assistantId: String,
+      status: String,
+      requiredAction: Option[RequiredAction] = None,
+      lastError: Option[Error] = None,
+      expiresAt: Option[Int] = None,
+      startedAt: Option[Int] = None,
+      cancelledAt: Option[Int] = None,
+      failedAt: Option[Int] = None,
+      completedAt: Option[Int] = None,
+      model: String,
+      instructions: Option[String] = None,
+      tools: Seq[Tool] = Seq.empty,
+      fileIds: Seq[String] = Seq.empty,
+      metadata: Map[String, String] = Map.empty,
+      usage: Option[Usage] = None
+  )
+
+  object RunData {
+    implicit val RunDataR: SnakePickle.Reader[RunData] = SnakePickle.macroR[RunData]
+  }
+
+  /** Details on the action required to continue the run. Will be null if no action is required
+    *
+    * [[https://platform.openai.com/docs/api-reference/runs/object]]
+    */
+  trait RequiredAction
+
+  implicit val requiredActionR: SnakePickle.Reader[RequiredAction] = SnakePickle
+    .reader[ujson.Value]
+    .map(json =>
+      json("type").str match {
+        case "submit_tool_outputs" =>
+          SnakePickle.read[SubmitToolOutputsRequiredAction](json)
+      }
+    )
+
+  /** @param type
+    *   For now, this is always submit_tool_outputs.
+    *
+    * @param submitToolOutputs
+    *   Details on the tool outputs needed for this run to continue.
+    */
+  case class SubmitToolOutputsRequiredAction(
+      `type`: String,
+      submitToolOutputs: SubmitToolOutputs
+  ) extends RequiredAction
+
+  object SubmitToolOutputsRequiredAction {
+    implicit val submitToolOutputsRequiredActionR: SnakePickle.Reader[SubmitToolOutputsRequiredAction] =
+      SnakePickle.macroR[SubmitToolOutputsRequiredAction]
+  }
+
+  /** @param toolCalls
+    *   A list of the relevant tool calls.
+    */
+  case class SubmitToolOutputs(toolCalls: Seq[ToolCall])
+
+  object SubmitToolOutputs {
+    implicit val submitToolOutputsR: SnakePickle.Reader[SubmitToolOutputs] = SnakePickle.macroR[SubmitToolOutputs]
+  }
+
+  /** The last error associated with this run
+    */
+  trait Error {
+
+    /** One of server_error, rate_limit_exceeded, or invalid_prompt.
+      */
+    def code: String
+
+    /** A human-readable description of the error.
+      */
+    def message: String
+  }
+
+  object Error {
+    implicit val errorR: SnakePickle.Reader[Error] = SnakePickle
+      .reader[ujson.Value]
+      .map(json =>
+        json("code").str match {
+          case "server_error" =>
+            SnakePickle.read[ServerError](json)
+          case "rate_limit_exceeded" =>
+            SnakePickle.read[RateLimitExceeded](json)
+          case "invalid_prompt" =>
+            SnakePickle.read[InvalidPrompt](json)
+        }
+      )
+
+    /** @param code
+      *   server_error
+      * @param message
+      *   A human-readable description of the error.
+      */
+    case class ServerError(code: String, message: String) extends Error
+
+    implicit val serverErrorR: SnakePickle.Reader[ServerError] = SnakePickle.macroR[ServerError]
+
+    /** @param code
+      *   rate_limit_exceeded
+      * @param message
+      *   A human-readable description of the error.
+      */
+    case class RateLimitExceeded(code: String, message: String) extends Error
+
+    implicit val rateLimitExceededR: SnakePickle.Reader[RateLimitExceeded] = SnakePickle.macroR[RateLimitExceeded]
+
+    /** @param code
+      *   invalid_prompt
+      * @param message
+      *   A human-readable description of the error.
+      */
+    case class InvalidPrompt(code: String, message: String) extends Error
+
+    implicit val invalidPromptR: SnakePickle.Reader[InvalidPrompt] = SnakePickle.macroR[InvalidPrompt]
+  }
+
+  /** @param object
+    *   Always "list"
+    * @param data
+    *   A list of run objects.
+    * @param firstId
+    * @param lastId
+    * @param hasMore
+    *   }
+    */
+  case class ListRunsResponse(
+      `object`: String = "list",
+      data: Seq[RunData],
+      firstId: String,
+      lastId: String,
+      hasMore: Boolean
+  )
+
+  object ListRunsResponse {
+    implicit val listRunsResponseR: SnakePickle.Reader[ListRunsResponse] = SnakePickle.macroR[ListRunsResponse]
+  }
+
+  /** Represents a step in execution of a run
+    *
+    * @param id
+    *   The identifier of the run step, which can be referenced in API endpoints.
+    *
+    * @param object
+    *   The object type, which is always thread.run.step.
+    *
+    * @param createdAt
+    *   The Unix timestamp (in seconds) for when the run step was created.
+    *
+    * @param assistantId
+    *   The ID of the assistant associated with the run step.
+    *
+    * @param threadId
+    *   The ID of the thread that was run.
+    *
+    * @param runId
+    *   The ID of the run that this run step is a part of.
+    *
+    * @param type
+    *   The type of run step, which can be either message_creation or tool_calls.
+    *
+    * @param status
+    *   The status of the run step, which can be either in_progress, cancelled, failed, completed, or expired.
+    *
+    * @param stepDetails
+    *   The details of the run step.
+    *
+    * @param lastError
+    *   The last error associated with this run step. Will be null if there are no errors.
+    *
+    * @param expiredAt
+    *   The Unix timestamp (in seconds) for when the run step expired. A step is considered expired if the parent run is expired.
+    *
+    * @param cancelledAt
+    *   The Unix timestamp (in seconds) for when the run step was cancelled.
+    *
+    * @param failedAt
+    *   The Unix timestamp (in seconds) for when the run step failed.
+    *
+    * @param completedAt
+    *   The Unix timestamp (in seconds) for when the run step completed.
+    *
+    * @param metadata
+    *   Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object
+    *   in a structured format. Keys can be a maximum of 64 characters long and values can be a maxium of 512 characters long.
+    *
+    * @param usage
+    *   Usage statistics related to the run step. This value will be null while the run step's status is in_progress.
+    */
+
+  case class RunStepData(
+      id: String,
+      `object`: String,
+      createdAt: Int,
+      assistantId: String,
+      threadId: String,
+      runId: String,
+      `type`: String,
+      status: String,
+      stepDetails: StepDetails,
+      lastError: Option[Error] = None,
+      expiredAt: Option[Int] = None,
+      cancelledAt: Option[Int] = None,
+      failedAt: Option[Int] = None,
+      completedAt: Option[Int] = None,
+      metadata: Map[String, String] = Map.empty,
+      usage: Option[Usage] = None
+  )
+
+  object RunStepData {
+    implicit val runStepDataR: SnakePickle.Reader[RunStepData] = SnakePickle.macroR[RunStepData]
+  }
+
+  /** The details of the run step.
+    */
+  trait StepDetails
+
+  object StepDetails {
+    implicit val stepDetailsR: SnakePickle.Reader[StepDetails] = SnakePickle
+      .reader[ujson.Value]
+      .map(json =>
+        json("type").str match {
+          case "message_creation" =>
+            SnakePickle.read[MessageCreation](json("message_creation"))
+          case "tool_calls" =>
+            SnakePickle.read[ToolCalls](json)
+        }
+      )
+  }
+
+  /** Details of the message creation by the run step
+    *
+    * @param messageId
+    *   The ID of the message that was created by this run step.
+    */
+  case class MessageCreation(messageId: String) extends StepDetails
+
+  object MessageCreation {
+    implicit val messageCreationR: SnakePickle.Reader[MessageCreation] = SnakePickle.macroR[MessageCreation]
+  }
+
+  /** Details of the tool call
+    * @param `type`
+    *   Always tool_calls.
+    * @param toolCalls
+    *   An array of tool calls the run step was involved in. These can be associated with one of three types of tools: code_interpreter,
+    *   retrieval, or function.
+    */
+  case class ToolCalls(`type`: String, toolCalls: Seq[ToolCall]) extends StepDetails
+
+  object ToolCalls {
+    implicit val toolCallsR: SnakePickle.Reader[ToolCalls] = SnakePickle.macroR[ToolCalls]
+  }
+
+  /** Details of the tool call
+    */
+
+  trait ToolCall
+
+  object ToolCall {
+    implicit val toolCallsR: SnakePickle.Reader[ToolCall] = SnakePickle
+      .reader[ujson.Value]
+      .map(json =>
+        json("type").str match {
+          case "code_interpreter" =>
+            SnakePickle.read[CodeInterpreterToolCall](json)
+          case "retrieval" =>
+            SnakePickle.read[RetrievalToolCall](json)
+          case "function" =>
+            SnakePickle.read[FunctionToolCall](json)
+        }
+      )
+  }
+
+  /** Details of the Code Interpreter tool call the run step was involved in.
+    * @param id
+    *   The ID of the tool call.
+    *
+    * @param type
+    *   The type of tool call. This is always going to be code_interpreter for this type of tool call.
+    *
+    * @param codeInterpreter
+    *   The Code Interpreter tool call definition.
+    */
+  case class CodeInterpreterToolCall(
+      id: String,
+      `type`: String
+      // TODO codeInterpreter
+  ) extends ToolCall
+
+  object CodeInterpreterToolCall {
+    implicit val codeInterpreterToolCallR: SnakePickle.Reader[CodeInterpreterToolCall] = SnakePickle.macroR[CodeInterpreterToolCall]
+  }
+
+  /** Retrieval tool call
+    * @param id
+    *   The ID of the tool call object.
+    *
+    * @param type
+    *   The type of tool call. This is always going to be retrieval for this type of tool call.
+    *
+    * @param retrieval
+    *   For now, this is always going to be an empty object.
+    */
+  case class RetrievalToolCall(
+      id: String,
+      `type`: String,
+      retrieval: Map[String, String]
+  ) extends ToolCall
+
+  object RetrievalToolCall {
+    implicit val retrievalToolCallR: SnakePickle.Reader[RetrievalToolCall] = SnakePickle.macroR[RetrievalToolCall]
+  }
+
+  /** Function tool call
+    *
+    * @param id
+    *   The ID of the tool call object.
+    * @param type
+    *   The type of tool call. This is always going to be function for this type of tool call.
+    * @param function
+    *   The definition of the function that was called.
+    */
+  case class FunctionToolCall(
+      id: String,
+      `type`: String,
+      function: FunctionCallResult
+  ) extends ToolCall
+
+  object FunctionToolCall {
+    implicit val functionToolCallR: SnakePickle.Reader[FunctionToolCall] = SnakePickle.macroR[FunctionToolCall]
+  }
+
+  /** The definition of the function that was called
+    *
+    * @param name
+    *   The name of the function.
+    *
+    * @param arguments
+    *   The arguments passed to the function.
+    *
+    * @param output
+    *   The output of the function. This will be null if the outputs have not been submitted yet.
+    */
+  case class FunctionCallResult(name: String, arguments: String, output: Option[String])
+  object FunctionCallResult {
+    implicit val functionCallResultR: SnakePickle.Reader[FunctionCallResult] = SnakePickle.macroR[FunctionCallResult]
+  }
+
+  /** @param object
+    *   Always "list"
+    * @param data
+    *   A list of run objects.
+    * @param firstId
+    * @param lastId
+    * @param hasMore
+    *   }
+    */
+  case class ListRunStepsResponse(
+      `object`: String = "list",
+      data: Seq[RunStepData],
+      firstId: String,
+      lastId: String,
+      hasMore: Boolean
+  )
+  object ListRunStepsResponse {
+    implicit val listRunStepsResponseR: SnakePickle.Reader[ListRunStepsResponse] = SnakePickle.macroR[ListRunStepsResponse]
+  }
+}
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index a55ec33..bedb57a 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -6,10 +6,10 @@ object Dependencies {
     val scalaTest = "3.2.17"
     val scalaTestCats = "1.5.0"
 
-    val sttpClient = "4.0.0-M8"
+    val sttpClient = "4.0.0-M9"
     val pekkoStreams = "1.0.2"
     val akkaStreams = "2.6.20"
-    val uPickle = "3.1.3"
+    val uPickle = "3.1.4"
   }
 
   object Libraries {
diff --git a/project/build.properties b/project/build.properties
index abbbce5..04267b1 100644
--- a/project/build.properties
+++ b/project/build.properties
@@ -1,1 +1,1 @@
-sbt.version=1.9.8
+sbt.version=1.9.9
diff --git a/project/plugins.sbt b/project/plugins.sbt
index 06513c7..bd002f8 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -1,4 +1,4 @@
-val sbtSoftwareMillVersion = "2.0.18"
+val sbtSoftwareMillVersion = "2.0.19"
 addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % sbtSoftwareMillVersion)
 addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % sbtSoftwareMillVersion)
 addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.9.2")


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/AssistantsFixture.scala b/core/src/test/scala/sttp/openai/fixtures/AssistantsFixture.scala
new file mode 100644
index 0000000..bacfca1
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/AssistantsFixture.scala
@@ -0,0 +1,180 @@
+package sttp.openai.fixtures
+
+object AssistantsFixture {
+  val jsonCreateAssistantRequest: String =
+    """
+      |{
+      |  "instructions": "You are a personal math tutor. When asked a question, write and run Python code to answer the question.",
+      |  "name": "Math Tutor",
+      |  "tools": [{"type": "code_interpreter"}],
+      |  "model": "gpt-4"
+      |}""".stripMargin
+
+  val jsonCreateAssistantResponse: String =
+    """
+      |{
+      |  "id": "asst_abc123",
+      |  "object": "assistant",
+      |  "created_at": 1698984975,
+      |  "name": "Math Tutor",
+      |  "description": null,
+      |  "model": "gpt-4",
+      |  "instructions": "You are a personal math tutor. When asked a question, write and run Python code to answer the question.",
+      |  "tools": [
+      |    {
+      |      "type": "code_interpreter"
+      |    }
+      |  ],
+      |  "file_ids": [],
+      |  "metadata": {}
+      |}
+      |""".stripMargin
+
+  val jsonCreateAssistantFileRequest: String =
+    """
+      |{
+      |  "file_id": "file-abc123"
+      |}
+      |""".stripMargin
+
+  val jsonCreateAssistantFileResponse: String =
+    """
+      |{
+      |  "id": "file-abc123",
+      |  "object": "assistant.file",
+      |  "created_at": 1699055364,
+      |  "assistant_id": "asst_abc123"
+      |}
+      |""".stripMargin
+
+  val jsonListAssistantsResponse: String =
+    """
+      |{
+      |  "object": "list",
+      |  "data": [
+      |    {
+      |      "id": "asst_abc123",
+      |      "object": "assistant",
+      |      "created_at": 1698982736,
+      |      "name": "Coding Tutor",
+      |      "description": null,
+      |      "model": "gpt-4",
+      |      "instructions": "You are a helpful assistant designed to make me better at coding!",
+      |      "tools": [],
+      |      "file_ids": [],
+      |      "metadata": {}
+      |    },
+      |    {
+      |      "id": "asst_abc456",
+      |      "object": "assistant",
+      |      "created_at": 1698982718,
+      |      "name": "My Assistant",
+      |      "description": null,
+      |      "model": "gpt-4",
+      |      "instructions": "You are a helpful assistant designed to make me better at coding!",
+      |      "tools": [],
+      |      "file_ids": [],
+      |      "metadata": {}
+      |    },
+      |    {
+      |      "id": "asst_abc789",
+      |      "object": "assistant",
+      |      "created_at": 1698982643,
+      |      "name": null,
+      |      "description": null,
+      |      "model": "gpt-4",
+      |      "instructions": null,
+      |      "tools": [],
+      |      "file_ids": [],
+      |      "metadata": {}
+      |    }
+      |  ],
+      |  "first_id": "asst_abc123",
+      |  "last_id": "asst_abc789",
+      |  "has_more": false
+      |}
+      |""".stripMargin
+
+  val jsonRetrieveAssistantResponse: String =
+    """
+      |{
+      |  "id": "asst_abc123",
+      |  "object": "assistant",
+      |  "created_at": 1699009709,
+      |  "name": "HR Helper",
+      |  "description": null,
+      |  "model": "gpt-4",
+      |  "instructions": "You are an HR bot, and you have access to files to answer employee questions about company policies.",
+      |  "tools": [
+      |    {
+      |      "type": "retrieval"
+      |    }
+      |  ],
+      |  "file_ids": [
+      |    "file-abc123"
+      |  ],
+      |  "metadata": {}
+      |}
+      |""".stripMargin
+
+  val jsonRetrieveAssistantFileResponse: String =
+    """
+      |{
+      |  "id": "file-abc123",
+      |  "object": "assistant.file",
+      |  "created_at": 1699055364,
+      |  "assistant_id": "asst_abc123"
+      |}
+      |""".stripMargin
+
+  val jsonModifyAssistantRequest: String =
+    """
+      |{
+      |  "instructions": "You are an HR bot, and you have access to files to answer employee questions about company policies. Always response with info from either of the files.",
+      |  "tools": [{"type": "retrieval"}],
+      |  "model": "gpt-4",
+      |  "file_ids": ["file-abc123", "file-abc456"]
+      |}
+      |""".stripMargin
+
+  val jsonModifyAssistantResponse: String =
+    """|
+       |{
+       |  "id": "asst_abc123",
+       |  "object": "assistant",
+       |  "created_at": 1699009709,
+       |  "name": "HR Helper",
+       |  "description": null,
+       |  "model": "gpt-4",
+       |  "instructions": "You are an HR bot, and you have access to files to answer employee questions about company policies. Always response with info from either of the files.",
+       |  "tools": [
+       |    {
+       |      "type": "retrieval"
+       |    }
+       |  ],
+       |  "file_ids": [
+       |    "file-abc123",
+       |    "file-abc456"
+       |  ],
+       |  "metadata": {}
+       |}
+       |""".stripMargin
+
+  val jsonDeleteAssistantResponse: String =
+    """
+      |{
+      |  "id": "asst_abc123",
+      |  "object": "assistant.deleted",
+      |  "deleted": true
+      |}
+      |""".stripMargin
+
+  val jsonDeleteAssistantFileResponse: String =
+    """
+      |{
+      |  "id": "file-abc123",
+      |  "object": "assistant.file.deleted",
+      |  "deleted": true
+      |}
+      |""".stripMargin
+}
diff --git a/core/src/test/scala/sttp/openai/fixtures/ThreadMessagesFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ThreadMessagesFixture.scala
new file mode 100644
index 0000000..397f586
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/ThreadMessagesFixture.scala
@@ -0,0 +1,173 @@
+package sttp.openai.fixtures
+
+object ThreadMessagesFixture {
+
+  val jsonCreateMessageRequest: String =
+    """{
+      |  "role": "user",
+      |  "content": "How does AI work? Explain it in simple terms."
+      |}""".stripMargin
+
+  val jsonCreateMessageResponse: String =
+    """
+      |{
+      |  "id": "msg_abc123",
+      |  "object": "thread.message",
+      |  "created_at": 1699017614 ,
+      |  "thread_id": "thread_abc123",
+      |  "role": "user",
+      |  "content": [
+      |    {
+      |      "type": "text",
+      |      "text": {
+      |        "value": "How does AI work? Explain it in simple terms.",
+      |        "annotations": []
+      |      }
+      |    }
+      |  ],
+      |  "file_ids": [],
+      |  "assistant_id": null,
+      |  "run_id": null,
+      |  "metadata": {}
+      |}
+      |""".stripMargin
+
+  val jsonListMessagesResponse: String =
+    """
+      |{
+      |  "object": "list",
+      |  "data": [
+      |    {
+      |      "id": "msg_abc123",
+      |      "object": "thread.message",
+      |      "created_at": 1699016383,
+      |      "thread_id": "thread_abc123",
+      |      "role": "user",
+      |      "content": [
+      |        {
+      |          "type": "text",
+      |          "text": {
+      |            "value": "How does AI work? Explain it in simple terms.",
+      |            "annotations": []
+      |          }
+      |        }
+      |      ],
+      |      "file_ids": [],
+      |      "assistant_id": null,
+      |      "run_id": null,
+      |      "metadata": {}
+      |    },
+      |    {
+      |      "id": "msg_abc456",
+      |      "object": "thread.message",
+      |      "created_at": 1699016383,
+      |      "thread_id": "thread_abc123",
+      |      "role": "user",
+      |      "content": [
+      |        {
+      |          "type": "text",
+      |          "text": {
+      |            "value": "Hello, what is AI?",
+      |            "annotations": []
+      |          }
+      |        }
+      |      ],
+      |      "file_ids": [
+      |        "file-abc123"
+      |      ],
+      |      "assistant_id": null,
+      |      "run_id": null,
+      |      "metadata": {}
+      |    }
+      |  ],
+      |  "first_id": "msg_abc123",
+      |  "last_id": "msg_abc456",
+      |  "has_more": false
+      |}
+      |""".stripMargin
+
+  val jsonListMessageFilesResponse: String =
+    """
+      |{
+      |  "object": "list",
+      |  "data": [
+      |    {
+      |      "id": "file-abc123",
+      |      "object": "thread.message.file",
+      |      "created_at": 1699061776,
+      |      "message_id": "msg_abc123"
+      |    },
+      |    {
+      |      "id": "file-abc123",
+      |      "object": "thread.message.file",
+      |      "created_at": 1699061776,
+      |      "message_id": "msg_abc123"
+      |    }
+      |  ],
+      |  "first_id": "file-abc123",
+      |  "last_id": "file-abc123",
+      |  "has_more": false
+      |}
+      |""".stripMargin
+
+  val jsonRetrieveMessageResponse: String =
+    """
+      |{
+      |  "id": "msg_abc123",
+      |  "object": "thread.message",
+      |  "created_at": 1699017614,
+      |  "thread_id": "thread_abc123",
+      |  "role": "user",
+      |  "content": [
+      |    {
+      |      "type": "text",
+      |      "text": {
+      |        "value": "How does AI work? Explain it in simple terms.",
+      |        "annotations": []
+      |      }
+      |    }
+      |  ],
+      |  "file_ids": [],
+      |  "assistant_id": null,
+      |  "run_id": null,
+      |  "metadata": {}
+      |}
+      |""".stripMargin
+
+  val jsonRetrieveMessageFileResponse: String =
+    """
+      |{
+      |  "id": "file-abc123",
+      |  "object": "thread.message.file",
+      |  "created_at": 1699061776,
+      |  "message_id": "msg_abc123"
+      |}
+      |""".stripMargin
+
+  val jsonModifyMessageResponse: String =
+    """
+      |{
+      |  "id": "msg_abc123",
+      |  "object": "thread.message",
+      |  "created_at": 1699017614,
+      |  "thread_id": "thread_abc123",
+      |  "role": "user",
+      |  "content": [
+      |    {
+      |      "type": "text",
+      |      "text": {
+      |        "value": "How does AI work? Explain it in simple terms.",
+      |        "annotations": []
+      |      }
+      |    }
+      |  ],
+      |  "file_ids": [],
+      |  "assistant_id": null,
+      |  "run_id": null,
+      |  "metadata": {
+      |    "modified": "true",
+      |    "user": "abc123"
+      |  }
+      |}
+      |""".stripMargin
+}
diff --git a/core/src/test/scala/sttp/openai/fixtures/ThreadRunsFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ThreadRunsFixture.scala
new file mode 100644
index 0000000..20293d5
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/ThreadRunsFixture.scala
@@ -0,0 +1,392 @@
+package sttp.openai.fixtures
+
+object ThreadRunsFixture {
+
+  val jsonCreateRunRequest: String =
+    """
+      |{
+      |  "assistant_id": "asst_abc123"
+      |}
+      |""".stripMargin
+
+  val jsonCreateRunResponse: String =
+    """
+      |{
+      |  "id": "run_abc123",
+      |  "object": "thread.run",
+      |  "created_at": 1699063290,
+      |  "assistant_id": "asst_abc123",
+      |  "thread_id": "thread_abc123",
+      |  "status": "queued",
+      |  "started_at": 1699063290,
+      |  "expires_at": null,
+      |  "cancelled_at": null,
+      |  "failed_at": null,
+      |  "completed_at": 1699063291,
+      |  "last_error": null,
+      |  "model": "gpt-4",
+      |  "instructions": null,
+      |  "tools": [
+      |    {
+      |      "type": "code_interpreter"
+      |    }
+      |  ],
+      |  "file_ids": [
+      |    "file-abc123",
+      |    "file-abc456"
+      |  ],
+      |  "metadata": {},
+      |  "usage": null
+      |}
+      |""".stripMargin
+
+  val jsonCreateThreadAndRunRequest: String =
+    """
+      |{
+      |  "assistant_id": "asst_abc123",
+      |  "thread": {
+      |    "messages": [
+      |      {"role": "user", "content": "Explain deep learning to a 5 year old."}
+      |    ]
+      |  }
+      |}
+      |""".stripMargin
+
+  val jsonCreateThreadAndRunResponse: String =
+    """
+      |{
+      |  "id": "run_abc123",
+      |  "object": "thread.run",
+      |  "created_at": 1699076792,
+      |  "assistant_id": "asst_abc123",
+      |  "thread_id": "thread_abc123",
+      |  "status": "queued",
+      |  "started_at": null,
+      |  "expires_at": 1699077392,
+      |  "cancelled_at": null,
+      |  "failed_at": null,
+      |  "completed_at": null,
+      |  "last_error": null,
+      |  "model": "gpt-4",
+      |  "instructions": "You are a helpful assistant.",
+      |  "tools": [],
+      |  "file_ids": [],
+      |  "metadata": {},
+      |  "usage": null
+      |}
+      |""".stripMargin
+
+  val jsonListRunsResponse: String =
+    """
+      |{
+      |  "object": "list",
+      |  "data": [
+      |    {
+      |      "id": "run_abc123",
+      |      "object": "thread.run",
+      |      "created_at": 1699075072,
+      |      "assistant_id": "asst_abc123",
+      |      "thread_id": "thread_abc123",
+      |      "status": "completed",
+      |      "started_at": 1699075072,
+      |      "expires_at": null,
+      |      "cancelled_at": null,
+      |      "failed_at": null,
+      |      "completed_at": 1699075073,
+      |      "last_error": null,
+      |      "model": "gpt-3.5-turbo",
+      |      "instructions": null,
+      |      "tools": [
+      |        {
+      |          "type": "code_interpreter"
+      |        }
+      |      ],
+      |      "file_ids": [
+      |        "file-abc123",
+      |        "file-abc456"
+      |      ],
+      |      "metadata": {},
+      |      "usage": {
+      |        "prompt_tokens": 123,
+      |        "completion_tokens": 456,
+      |        "total_tokens": 579
+      |      }
+      |    },
+      |    {
+      |      "id": "run_abc456",
+      |      "object": "thread.run",
+      |      "created_at": 1699063290,
+      |      "assistant_id": "asst_abc123",
+      |      "thread_id": "thread_abc123",
+      |      "status": "completed",
+      |      "started_at": 1699063290,
+      |      "expires_at": null,
+      |      "cancelled_at": null,
+      |      "failed_at": null,
+      |      "completed_at": 1699063291,
+      |      "last_error": null,
+      |      "model": "gpt-3.5-turbo",
+      |      "instructions": null,
+      |      "tools": [
+      |        {
+      |          "type": "code_interpreter"
+      |        }
+      |      ],
+      |      "file_ids": [
+      |        "file-abc123",
+      |        "file-abc456"
+      |      ],
+      |      "metadata": {},
+      |      "usage": {
+      |        "prompt_tokens": 123,
+      |        "completion_tokens": 456,
+      |        "total_tokens": 579
+      |      }
+      |    }
+      |  ],
+      |  "first_id": "run_abc123",
+      |  "last_id": "run_abc456",
+      |  "has_more": false
+      |}
+      |""".stripMargin
+
+  val jsonListRunStepsResponse: String =
+    """
+      |{
+      |  "object": "list",
+      |  "data": [
+      |    {
+      |      "id": "step_abc123",
+      |      "object": "thread.run.step",
+      |      "created_at": 1699063291,
+      |      "run_id": "run_abc123",
+      |      "assistant_id": "asst_abc123",
+      |      "thread_id": "thread_abc123",
+      |      "type": "message_creation",
+      |      "status": "completed",
+      |      "cancelled_at": null,
+      |      "completed_at": 1699063291,
+      |      "expired_at": null,
+      |      "failed_at": null,
+      |      "last_error": null,
+      |      "step_details": {
+      |        "type": "message_creation",
+      |        "message_creation": {
+      |          "message_id": "msg_abc123"
+      |        }
+      |      },
+      |      "usage": {
+      |        "prompt_tokens": 123,
+      |        "completion_tokens": 456,
+      |        "total_tokens": 579
+      |      }
+      |    }
+      |  ],
+      |  "first_id": "step_abc123",
+      |  "last_id": "step_abc456",
+      |  "has_more": false
+      |}
+      |""".stripMargin
+
+  val jsonRetrieveRunResponse: String =
+    """
+      |{
+      |  "id": "run_abc123",
+      |  "object": "thread.run",
+      |  "created_at": 1699075072,
+      |  "assistant_id": "asst_abc123",
+      |  "thread_id": "thread_abc123",
+      |  "status": "completed",
+      |  "started_at": 1699075072,
+      |  "expires_at": null,
+      |  "cancelled_at": null,
+      |  "failed_at": null,
+      |  "completed_at": 1699075073,
+      |  "last_error": null,
+      |  "model": "gpt-3.5-turbo",
+      |  "instructions": null,
+      |  "tools": [
+      |    {
+      |      "type": "code_interpreter"
+      |    }
+      |  ],
+      |  "file_ids": [
+      |    "file-abc123",
+      |    "file-abc456"
+      |  ],
+      |  "metadata": {},
+      |  "usage": {
+      |    "prompt_tokens": 123,
+      |    "completion_tokens": 456,
+      |    "total_tokens": 579
+      |  }
+      |}
+      |""".stripMargin
+
+  val jsonRetrieveRunStepResponse: String =
+    """
+      |{
+      |  "id": "step_abc123",
+      |  "object": "thread.run.step",
+      |  "created_at": 1699063291,
+      |  "run_id": "run_abc123",
+      |  "assistant_id": "asst_abc123",
+      |  "thread_id": "thread_abc123",
+      |  "type": "message_creation",
+      |  "status": "completed",
+      |  "cancelled_at": null,
+      |  "completed_at": 1699063291,
+      |  "expired_at": null,
+      |  "failed_at": null,
+      |  "last_error": null,
+      |  "step_details": {
+      |    "type": "message_creation",
+      |    "message_creation": {
+      |      "message_id": "msg_abc123"
+      |    }
+      |  },
+      |  "usage": {
+      |    "prompt_tokens": 123,
+      |    "completion_tokens": 456,
+      |    "total_tokens": 579
+      |  }
+      |}
+      |""".stripMargin
+
+  val jsonModifyRunRequest: String =
+    """
+      |{
+      |  "metadata": {
+      |     "user_id": "user_abc123"
+      |   }
+      |}
+      |""".stripMargin
+
+  val jsonModifyRunResponse: String =
+    """
+      |{
+      |  "id": "run_abc123",
+      |  "object": "thread.run",
+      |  "created_at": 1699075072,
+      |  "assistant_id": "asst_abc123",
+      |  "thread_id": "thread_abc123",
+      |  "status": "completed",
+      |  "started_at": 1699075072,
+      |  "expires_at": null,
+      |  "cancelled_at": null,
+      |  "failed_at": null,
+      |  "completed_at": 1699075073,
+      |  "last_error": null,
+      |  "model": "gpt-3.5-turbo",
+      |  "instructions": null,
+      |  "tools": [
+      |    {
+      |      "type": "code_interpreter"
+      |    }
+      |  ],
+      |  "file_ids": [
+      |    "file-abc123",
+      |    "file-abc456"
+      |  ],
+      |  "metadata": {
+      |    "user_id": "user_abc123"
+      |  },
+      |  "usage": {
+      |    "prompt_tokens": 123,
+      |    "completion_tokens": 456,
+      |    "total_tokens": 579
+      |  }
+      |}
+      |""".stripMargin
+
+  val jsonSubmitToolOutputsRequest: String =
+    """
+      |{
+      |  "tool_outputs": [
+      |    {
+      |      "tool_call_id": "call_abc123",
+      |      "output": "28C"
+      |    }
+      |  ]
+      |}
+      |""".stripMargin
+
+  val jsonSubmitToolOutputsResponse: String =
+    """
+      |{
+      |  "id": "run_abc123",
+      |  "object": "thread.run",
+      |  "created_at": 1699075592,
+      |  "assistant_id": "asst_abc123",
+      |  "thread_id": "thread_abc123",
+      |  "status": "queued",
+      |  "started_at": 1699075592,
+      |  "expires_at": 1699076192,
+      |  "cancelled_at": null,
+      |  "failed_at": null,
+      |  "completed_at": null,
+      |  "last_error": null,
+      |  "model": "gpt-4",
+      |  "instructions": "You tell the weather.",
+      |  "tools": [
+      |    {
+      |      "type": "function",
+      |      "function": {
+      |        "name": "get_weather",
+      |        "description": "Determine weather in my location",
+      |        "parameters": {
+      |          "type": "object",
+      |          "properties": {
+      |            "location": {
+      |              "type": "string",
+      |              "description": "The city and state e.g. San Francisco, CA"
+      |            },
+      |            "unit": {
+      |              "type": "string",
+      |              "enum": [
+      |                "c",
+      |                "f"
+      |              ]
+      |            }
+      |          },
+      |          "required": [
+      |            "location"
+      |          ]
+      |        }
+      |      }
+      |    }
+      |  ],
+      |  "file_ids": [],
+      |  "metadata": {},
+      |  "usage": null
+      |}
+      |""".stripMargin
+
+  val jsonCancelRunResponse: String =
+    """
+      |{
+      |  "id": "run_abc123",
+      |  "object": "thread.run",
+      |  "created_at": 1699076126,
+      |  "assistant_id": "asst_abc123",
+      |  "thread_id": "thread_abc123",
+      |  "status": "cancelling",
+      |  "started_at": 1699076126,
+      |  "expires_at": 1699076726,
+      |  "cancelled_at": null,
+      |  "failed_at": null,
+      |  "completed_at": null,
+      |  "last_error": null,
+      |  "model": "gpt-4",
+      |  "instructions": "You summarize books.",
+      |  "tools": [
+      |    {
+      |      "type": "retrieval"
+      |    }
+      |  ],
+      |  "file_ids": [],
+      |  "metadata": {},
+      |  "usage": null
+      |}
+      |""".stripMargin
+}
diff --git a/core/src/test/scala/sttp/openai/fixtures/ThreadsFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ThreadsFixture.scala
new file mode 100644
index 0000000..b4645c5
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/ThreadsFixture.scala
@@ -0,0 +1,57 @@
+package sttp.openai.fixtures
+
+object ThreadsFixture {
+  val jsonCreateEmptyThreadRequest: String = """{}
+      |""".stripMargin
+
+  val jsonCreateThreadWithMessagesRequest: String = """{
+    |    "messages": [{
+    |      "role": "user",
+    |      "content": "Hello, what is AI?",
+    |      "file_ids": ["file-abc123"]
+    |    }, {
+    |      "role": "user",
+    |      "content": "How does AI work? Explain it in simple terms."
+    |    }]
+    |  }""".stripMargin
+
+  val jsonCreateThreadWithMessagesAndMetadataRequest: String = """{
+    |    "messages": [{
+    |      "role": "user",
+    |      "content": "Hello, what is AI?",
+    |      "file_ids": ["file-abc123"]
+    |    }, {
+    |      "role": "user",
+    |      "content": "How does AI work? Explain it in simple terms."
+    |    }],
+    |    "metadata": {
+    |      "modified": "true",
+    |      "user": "abc123"
+    |    }
+    |}""".stripMargin
+
+  val jsonCreateEmptyThreadResponse: String = """{
+    |  "id": "thread_abc123",
+    |  "object": "thread",
+    |  "created_at": 1699012949,
+    |  "metadata": {}
+    |}""".stripMargin
+
+  val jsonCreateThreadWithMessagesAndMetadataResponse: String = """{
+    |  "id": "thread_abc123",
+    |  "object": "thread",
+    |  "created_at": 1699014083,
+    |  "metadata": {
+    |    "modified": "true",
+    |    "user": "abc123"
+    |  }
+    |}""".stripMargin
+
+  val jsonDeleteThreadResponse: String = """{
+   |  "id": "thread_abc123",
+   |  "object": "thread.deleted",
+   |  "deleted": true
+   |}
+   |""".stripMargin
+
+}
diff --git a/core/src/test/scala/sttp/openai/requests/assistants/AssistantsDataSpec.scala b/core/src/test/scala/sttp/openai/requests/assistants/AssistantsDataSpec.scala
new file mode 100644
index 0000000..fe6fc87
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/assistants/AssistantsDataSpec.scala
@@ -0,0 +1,347 @@
+package sttp.openai.requests.assistants
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.client4.IsOption._
+import sttp.openai.fixtures
+import sttp.openai.json.SnakePickle
+import sttp.openai.json.SttpUpickleApiExtension
+import sttp.openai.requests.completions.chat.message.Tool.CodeInterpreterTool
+import sttp.openai.requests.completions.chat.message.Tool.RetrievalTool
+
+class AssistantsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+
+  "Given create assistant request" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = AssistantsRequestBody.CreateAssistantBody(
+      instructions = Some("You are a personal math tutor. When asked a question, write and run Python code to answer the question."),
+      name = Some("Math Tutor"),
+      tools = Seq(CodeInterpreterTool),
+      model = "gpt-4"
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.AssistantsFixture.jsonCreateAssistantRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create assistant response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.AssistantData._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonCreateAssistantResponse
+    val expectedResponse: AssistantData = AssistantData(
+      id = "asst_abc123",
+      `object` = "assistant",
+      createdAt = 1698984975,
+      name = Some("Math Tutor"),
+      description = None,
+      model = "gpt-4",
+      instructions = Some("You are a personal math tutor. When asked a question, write and run Python code to answer the question."),
+      tools = Seq(
+        CodeInterpreterTool
+      ),
+      fileIds = Seq(),
+      metadata = Map.empty
+    )
+
+    // when
+    val givenResponse: Either[Exception, AssistantData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given create assistant file request" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = AssistantsRequestBody.CreateAssistantFileBody(
+      fileId = "file-abc123"
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.AssistantsFixture.jsonCreateAssistantFileRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create assistant file response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.AssistantFileData._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonCreateAssistantFileResponse
+    val expectedResponse: AssistantFileData = AssistantFileData(
+      id = "file-abc123",
+      `object` = "assistant.file",
+      createdAt = 1699055364,
+      assistantId = "asst_abc123"
+    )
+
+    // when
+    val givenResponse: Either[Exception, AssistantFileData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given list assistants response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.ListAssistantsResponse._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonListAssistantsResponse
+    val expectedResponse: ListAssistantsResponse = ListAssistantsResponse(
+      `object` = "list",
+      data = Seq(
+        AssistantData(
+          id = "asst_abc123",
+          `object` = "assistant",
+          createdAt = 1698982736,
+          name = Some("Coding Tutor"),
+          description = None,
+          model = "gpt-4",
+          instructions = Some("You are a helpful assistant designed to make me better at coding!"),
+          tools = Seq(),
+          fileIds = Seq(),
+          metadata = Map.empty
+        ),
+        AssistantData(
+          id = "asst_abc456",
+          `object` = "assistant",
+          createdAt = 1698982718,
+          name = Some("My Assistant"),
+          description = None,
+          model = "gpt-4",
+          instructions = Some("You are a helpful assistant designed to make me better at coding!"),
+          tools = Seq(),
+          fileIds = Seq(),
+          metadata = Map.empty
+        ),
+        AssistantData(
+          id = "asst_abc789",
+          `object` = "assistant",
+          createdAt = 1698982643,
+          name = None,
+          description = None,
+          model = "gpt-4",
+          instructions = None,
+          tools = Seq(),
+          fileIds = Seq(),
+          metadata = Map.empty
+        )
+      ),
+      firstId = "asst_abc123",
+      lastId = "asst_abc789",
+      hasMore = false
+    )
+
+    // when
+    val givenResponse: Either[Exception, ListAssistantsResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given list assistant files response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.ListAssistantsResponse._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonListAssistantsResponse
+    val expectedResponse: ListAssistantsResponse = ListAssistantsResponse(
+      `object` = "list",
+      data = Seq(
+        AssistantData(
+          id = "asst_abc123",
+          `object` = "assistant",
+          createdAt = 1698982736,
+          name = Some("Coding Tutor"),
+          description = None,
+          model = "gpt-4",
+          instructions = Some("You are a helpful assistant designed to make me better at coding!"),
+          tools = Seq(),
+          fileIds = Seq(),
+          metadata = Map.empty
+        ),
+        AssistantData(
+          id = "asst_abc456",
+          `object` = "assistant",
+          createdAt = 1698982718,
+          name = Some("My Assistant"),
+          description = None,
+          model = "gpt-4",
+          instructions = Some("You are a helpful assistant designed to make me better at coding!"),
+          tools = Seq(),
+          fileIds = Seq(),
+          metadata = Map.empty
+        ),
+        AssistantData(
+          id = "asst_abc789",
+          `object` = "assistant",
+          createdAt = 1698982643,
+          name = None,
+          description = None,
+          model = "gpt-4",
+          instructions = None,
+          tools = Seq(),
+          fileIds = Seq(),
+          metadata = Map.empty
+        )
+      ),
+      firstId = "asst_abc123",
+      lastId = "asst_abc789",
+      hasMore = false
+    )
+
+    // when
+    val givenResponse: Either[Exception, ListAssistantsResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given retrieve assistant response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.AssistantData._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonRetrieveAssistantResponse
+    val expectedResponse: AssistantData = AssistantData(
+      id = "asst_abc123",
+      `object` = "assistant",
+      createdAt = 1699009709,
+      name = Some("HR Helper"),
+      description = None,
+      model = "gpt-4",
+      instructions = Some("You are an HR bot, and you have access to files to answer employee questions about company policies."),
+      tools = Seq(
+        RetrievalTool
+      ),
+      fileIds = Seq("file-abc123"),
+      metadata = Map.empty
+    )
+
+    // when
+    val givenResponse: Either[Exception, AssistantData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given retrieve assistant file response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.AssistantFileData._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonRetrieveAssistantFileResponse
+    val expectedResponse: AssistantFileData = AssistantFileData(
+      id = "file-abc123",
+      `object` = "assistant.file",
+      createdAt = 1699055364,
+      assistantId = "asst_abc123"
+    )
+
+    // when
+    val givenResponse: Either[Exception, AssistantFileData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given modify assistant request" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = AssistantsRequestBody.ModifyAssistantBody(
+      instructions = Some(
+        "You are an HR bot, and you have access to files to answer employee questions about company policies. Always response with info from either of the files."
+      ),
+      tools = Seq(RetrievalTool),
+      model = Some("gpt-4"),
+      fileIds = Seq("file-abc123", "file-abc456")
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.AssistantsFixture.jsonModifyAssistantRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given modify assistant response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.AssistantData._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonModifyAssistantResponse
+    val expectedResponse: AssistantData = AssistantData(
+      id = "asst_abc123",
+      `object` = "assistant",
+      createdAt = 1699009709,
+      name = Some("HR Helper"),
+      description = None,
+      model = "gpt-4",
+      instructions = Some(
+        "You are an HR bot, and you have access to files to answer employee questions about company policies. Always response with info from either of the files."
+      ),
+      tools = Seq(
+        RetrievalTool
+      ),
+      fileIds = Seq("file-abc123", "file-abc456"),
+      metadata = Map.empty
+    )
+
+    // when
+    val givenResponse: Either[Exception, AssistantData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given delete assistant response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.DeleteAssistantResponse._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonDeleteAssistantResponse
+    val expectedResponse: DeleteAssistantResponse = DeleteAssistantResponse(
+      id = "asst_abc123",
+      `object` = "assistant.deleted",
+      deleted = true
+    )
+
+    // when
+    val givenResponse: Either[Exception, DeleteAssistantResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given delete assistant file response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.assistants.AssistantsResponseData.DeleteAssistantFileResponse._
+    import sttp.openai.requests.assistants.AssistantsResponseData._
+
+    // given
+    val jsonResponse = fixtures.AssistantsFixture.jsonDeleteAssistantFileResponse
+    val expectedResponse: DeleteAssistantFileResponse = DeleteAssistantFileResponse(
+      id = "file-abc123",
+      `object` = "assistant.file.deleted",
+      deleted = true
+    )
+
+    // when
+    val givenResponse: Either[Exception, DeleteAssistantFileResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+}
diff --git a/core/src/test/scala/sttp/openai/requests/threads/ThreadsDataSpec.scala b/core/src/test/scala/sttp/openai/requests/threads/ThreadsDataSpec.scala
new file mode 100644
index 0000000..bada91c
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/threads/ThreadsDataSpec.scala
@@ -0,0 +1,141 @@
+package sttp.openai.requests.threads
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.client4.IsOption._
+import sttp.openai.fixtures
+import sttp.openai.json.SnakePickle
+import sttp.openai.json.SttpUpickleApiExtension
+import sttp.openai.requests.threads.messages.ThreadMessagesRequestBody.CreateMessage
+class ThreadsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+
+  "Given empty create thread request as case class" should "be properly serialized to Json" in {
+
+    // given
+    val givenRequest = ThreadsRequestBody.CreateThreadBody()
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.ThreadsFixture.jsonCreateEmptyThreadRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create threads request with messages" should "be properly serialized to Json" in {
+
+    // given
+    val givenRequest = ThreadsRequestBody.CreateThreadBody(
+      messages = Some(
+        Seq(
+          CreateMessage(
+            role = "user",
+            content = "Hello, what is AI?",
+            file_ids = Seq("file-abc123")
+          ),
+          CreateMessage(
+            role = "user",
+            content = "How does AI work? Explain it in simple terms."
+          )
+        )
+      )
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.ThreadsFixture.jsonCreateThreadWithMessagesRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create threads request with messages and metadata" should "be properly serialized to Json" in {
+
+    // given
+    val givenRequest = ThreadsRequestBody.CreateThreadBody(
+      messages = Some(
+        Seq(
+          CreateMessage(
+            role = "user",
+            content = "Hello, what is AI?",
+            file_ids = Seq("file-abc123")
+          ),
+          CreateMessage(
+            role = "user",
+            content = "How does AI work? Explain it in simple terms."
+          )
+        )
+      ),
+      metadata = Some(Map("modified" -> "true", "user" -> "abc123"))
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.ThreadsFixture.jsonCreateThreadWithMessagesAndMetadataRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create empty thread response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.ThreadsResponseData.ThreadData._
+    import sttp.openai.requests.threads.ThreadsResponseData._
+
+    // given
+    val jsonResponse = fixtures.ThreadsFixture.jsonCreateEmptyThreadResponse
+    val expectedResponse: ThreadData = ThreadData(
+      id = "thread_abc123",
+      `object` = "thread",
+      createdAt = Some(1699012949)
+    )
+
+    // when
+    val givenResponse: Either[Exception, ThreadData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given create thread with messages and metadata response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.ThreadsResponseData.ThreadData._
+    import sttp.openai.requests.threads.ThreadsResponseData._
+
+    // given
+    val jsonResponse = fixtures.ThreadsFixture.jsonCreateThreadWithMessagesAndMetadataResponse
+    val expectedResponse: ThreadData = ThreadData(
+      id = "thread_abc123",
+      `object` = "thread",
+      createdAt = Some(1699014083),
+      metadata = Map("modified" -> "true", "user" -> "abc123")
+    )
+
+    // when
+    val givenResponse: Either[Exception, ThreadData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given delete thread response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.ThreadsResponseData.DeleteThreadResponse._
+    import sttp.openai.requests.threads.ThreadsResponseData._
+
+    // given
+    val jsonResponse = fixtures.ThreadsFixture.jsonDeleteThreadResponse
+    val expectedResponse: DeleteThreadResponse = DeleteThreadResponse(
+      id = "thread_abc123",
+      `object` = "thread.deleted",
+      deleted = true
+    )
+
+    // when
+    val givenResponse: Either[Exception, DeleteThreadResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+}
diff --git a/core/src/test/scala/sttp/openai/requests/threads/messages/ThreadMessagesDataSpec.scala b/core/src/test/scala/sttp/openai/requests/threads/messages/ThreadMessagesDataSpec.scala
new file mode 100644
index 0000000..1178a31
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/threads/messages/ThreadMessagesDataSpec.scala
@@ -0,0 +1,241 @@
+package sttp.openai.requests.threads.messages
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.fixtures
+import sttp.openai.json.{SnakePickle, SttpUpickleApiExtension}
+import sttp.openai.requests.threads.messages.ThreadMessagesResponseData.Content.{TextContent, TextContentValue}
+import sttp.openai.requests.threads.messages.ThreadMessagesResponseData.{ListMessageFilesResponse, ListMessagesResponse, MessageData, MessageFileData}
+
+class ThreadMessagesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+
+  "Given create thread message request as case class" should "be properly serialized to Json" in {
+
+    // given
+    val givenRequest = ThreadMessagesRequestBody.CreateMessage(
+      role = "user",
+      content = "How does AI work? Explain it in simple terms."
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.ThreadMessagesFixture.jsonCreateMessageRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create thread message response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.messages.ThreadMessagesResponseData.MessageData._
+
+    // given
+    val jsonResponse = fixtures.ThreadMessagesFixture.jsonCreateMessageResponse
+    val expectedResponse: MessageData = MessageData(
+      id = "msg_abc123",
+      `object` = "thread.message",
+      createdAt = 1699017614,
+      threadId = Some("thread_abc123"),
+      role = "user",
+      content = Seq(
+        TextContent(
+          `type` = "text",
+          text = TextContentValue(
+            value = "How does AI work? Explain it in simple terms.",
+            annotations = Seq.empty
+          )
+        )
+      ),
+      fileIds = Seq.empty,
+      assistantId = None,
+      runId = None,
+      metadata = Map.empty
+    )
+
+    // when
+    val givenResponse: Either[Exception, MessageData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given list messages response as Json" should "be properly deserialized to case class" in {
+    import ListMessagesResponse._
+    // given
+    val jsonResponse = fixtures.ThreadMessagesFixture.jsonListMessagesResponse
+    val expectedResponse: ListMessagesResponse = ListMessagesResponse(
+      `object` = "list",
+      data = Seq(
+        MessageData(
+          id = "msg_abc123",
+          `object` = "thread.message",
+          createdAt = 1699016383,
+          threadId = Some("thread_abc123"),
+          role = "user",
+          content = Seq(
+            TextContent(
+              `type` = "text",
+              text = TextContentValue(
+                value = "How does AI work? Explain it in simple terms.",
+                annotations = Seq.empty
+              )
+            )
+          ),
+          fileIds = Seq.empty,
+          assistantId = None,
+          runId = None,
+          metadata = Map.empty
+        ),
+        MessageData(
+          id = "msg_abc456",
+          `object` = "thread.message",
+          createdAt = 1699016383,
+          threadId = Some("thread_abc123"),
+          role = "user",
+          content = Seq(
+            TextContent(
+              `type` = "text",
+              text = TextContentValue(
+                value = "Hello, what is AI?",
+                annotations = Seq.empty
+              )
+            )
+          ),
+          fileIds = Seq(
+            "file-abc123"
+          ),
+          assistantId = None,
+          runId = None,
+          metadata = Map.empty
+        )
+      ),
+      firstId = "msg_abc123",
+      lastId = "msg_abc456",
+      hasMore = false
+    )
+
+    // when
+    val givenResponse: Either[Exception, ListMessagesResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given list message files response as Json" should "be properly deserialized to case class" in {
+    import ListMessageFilesResponse._
+    // given
+    val jsonResponse = fixtures.ThreadMessagesFixture.jsonListMessageFilesResponse
+    val expectedResponse: ListMessageFilesResponse = ListMessageFilesResponse(
+      `object` = "list",
+      data = Seq(
+        MessageFileData(
+          id = "file-abc123",
+          `object` = "thread.message.file",
+          createdAt = 1699061776,
+          messageId = "msg_abc123"
+        ),
+        MessageFileData(
+          id = "file-abc123",
+          `object` = "thread.message.file",
+          createdAt = 1699061776,
+          messageId = "msg_abc123"
+        )
+      ),
+      firstId = "file-abc123",
+      lastId = "file-abc123",
+      hasMore = false
+    )
+
+    // when
+    val givenResponse: Either[Exception, ListMessageFilesResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given retrieve message response as Json" should "be properly deserialized to case class" in {
+    import MessageData._
+    // given
+    val jsonResponse = fixtures.ThreadMessagesFixture.jsonRetrieveMessageResponse
+    val expectedResponse: MessageData = MessageData(
+      id = "msg_abc123",
+      `object` = "thread.message",
+      createdAt = 1699017614,
+      threadId = Some("thread_abc123"),
+      role = "user",
+      content = Seq(
+        TextContent(
+          `type` = "text",
+          text = TextContentValue(
+            value = "How does AI work? Explain it in simple terms.",
+            annotations = Seq.empty
+          )
+        )
+      ),
+      fileIds = Seq.empty,
+      assistantId = None,
+      runId = None,
+      metadata = Map.empty
+    )
+
+    // when
+    val givenResponse: Either[Exception, MessageData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given retrieve message file response as Json" should "be properly deserialized to case class" in {
+    import MessageFileData._
+    // given
+    val jsonResponse = fixtures.ThreadMessagesFixture.jsonRetrieveMessageFileResponse
+    val expectedResponse: MessageFileData = MessageFileData(
+      id = "file-abc123",
+      `object` = "thread.message.file",
+      createdAt = 1699061776,
+      messageId = "msg_abc123"
+    )
+
+    // when
+    val givenResponse: Either[Exception, MessageFileData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given modify message response as Json" should "be properly deserialized to case class" in {
+    import MessageData._
+    // given
+    val jsonResponse = fixtures.ThreadMessagesFixture.jsonModifyMessageResponse
+    val expectedResponse: MessageData = MessageData(
+      id = "msg_abc123",
+      `object` = "thread.message",
+      createdAt = 1699017614,
+      threadId = Some("thread_abc123"),
+      role = "user",
+      content = Seq(
+        TextContent(
+          `type` = "text",
+          text = TextContentValue(
+            value = "How does AI work? Explain it in simple terms.",
+            annotations = Seq.empty
+          )
+        )
+      ),
+      fileIds = Seq.empty,
+      assistantId = None,
+      runId = None,
+      metadata = Map(
+        "modified" -> "true",
+        "user" -> "abc123"
+      )
+    )
+
+    // when
+    val givenResponse: Either[Exception, MessageData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+}
diff --git a/core/src/test/scala/sttp/openai/requests/threads/runs/ThreadRunsDataSpec.scala b/core/src/test/scala/sttp/openai/requests/threads/runs/ThreadRunsDataSpec.scala
new file mode 100644
index 0000000..b2c0c61
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/threads/runs/ThreadRunsDataSpec.scala
@@ -0,0 +1,462 @@
+package sttp.openai.requests.threads.runs
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.fixtures
+import sttp.openai.json.{SnakePickle, SttpUpickleApiExtension}
+import sttp.openai.requests.completions.chat.message.Tool.{CodeInterpreterTool, FunctionTool, RetrievalTool}
+import sttp.openai.requests.completions.Usage
+import sttp.openai.requests.threads.ThreadsRequestBody.CreateThreadBody
+import sttp.openai.requests.threads.messages.ThreadMessagesRequestBody.CreateMessage
+import sttp.openai.requests.threads.runs.ThreadRunsRequestBody.ToolOutput
+import sttp.openai.requests.threads.runs.ThreadRunsResponseData.{ListRunsResponse, ListRunStepsResponse, MessageCreation, RunStepData}
+import ujson.{Arr, Obj, Str}
+
+class ThreadRunsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+
+  "Given create thread run request as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = ThreadRunsRequestBody.CreateRun(
+      assistantId = "asst_abc123"
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.ThreadRunsFixture.jsonCreateRunRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create thread run response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonCreateRunResponse
+    val expectedResponse: RunData = RunData(
+      id = "run_abc123",
+      `object` = "thread.run",
+      createdAt = 1699063290,
+      assistantId = "asst_abc123",
+      threadId = "thread_abc123",
+      status = "queued",
+      startedAt = Some(1699063290),
+      expiresAt = None,
+      cancelledAt = None,
+      failedAt = None,
+      completedAt = Some(1699063291),
+      lastError = None,
+      model = "gpt-4",
+      instructions = None,
+      tools = Seq(CodeInterpreterTool),
+      fileIds = Seq(
+        "file-abc123",
+        "file-abc456"
+      ),
+      metadata = Map.empty,
+      usage = None
+    )
+
+    // when
+    val givenResponse: Either[Exception, RunData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given create thread and run request as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = ThreadRunsRequestBody.CreateThreadAndRun(
+      assistantId = "asst_abc123",
+      thread = CreateThreadBody(
+        messages = Some(Seq(CreateMessage(role = "user", content = "Explain deep learning to a 5 year old.")))
+      )
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.ThreadRunsFixture.jsonCreateThreadAndRunRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given create thread and run response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonCreateThreadAndRunResponse
+    val expectedResponse: RunData = RunData(
+      id = "run_abc123",
+      `object` = "thread.run",
+      createdAt = 1699076792,
+      assistantId = "asst_abc123",
+      threadId = "thread_abc123",
+      status = "queued",
+      startedAt = None,
+      expiresAt = Some(1699077392),
+      cancelledAt = None,
+      failedAt = None,
+      completedAt = None,
+      lastError = None,
+      model = "gpt-4",
+      instructions = Some("You are a helpful assistant."),
+      tools = Seq.empty,
+      fileIds = Seq.empty,
+      metadata = Map.empty,
+      usage = None
+    )
+
+    // when
+    val givenResponse: Either[Exception, RunData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given list runs response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.ListRunsResponse._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonListRunsResponse
+    val expectedResponse: ListRunsResponse =
+      ListRunsResponse(
+        `object` = "list",
+        data = Seq(
+          RunData(
+            id = "run_abc123",
+            `object` = "thread.run",
+            createdAt = 1699075072,
+            assistantId = "asst_abc123",
+            threadId = "thread_abc123",
+            status = "completed",
+            startedAt = Some(1699075072),
+            expiresAt = None,
+            cancelledAt = None,
+            failedAt = None,
+            completedAt = Some(1699075073),
+            lastError = None,
+            model = "gpt-3.5-turbo",
+            instructions = None,
+            tools = Seq(CodeInterpreterTool),
+            fileIds = Seq("file-abc123", "file-abc456"),
+            metadata = Map.empty,
+            usage = Some(Usage(promptTokens = 123, completionTokens = 456, totalTokens = 579))
+          ),
+          RunData(
+            id = "run_abc456",
+            `object` = "thread.run",
+            createdAt = 1699063290,
+            assistantId = "asst_abc123",
+            threadId = "thread_abc123",
+            status = "completed",
+            startedAt = Some(1699063290),
+            expiresAt = None,
+            cancelledAt = None,
+            failedAt = None,
+            completedAt = Some(1699063291),
+            lastError = None,
+            model = "gpt-3.5-turbo",
+            instructions = None,
+            tools = Seq(CodeInterpreterTool),
+            fileIds = Seq("file-abc123", "file-abc456"),
+            metadata = Map.empty,
+            usage = Some(
+              Usage(
+                promptTokens = 123,
+                completionTokens = 456,
+                totalTokens = 579
+              )
+            )
+          )
+        ),
+        firstId = "run_abc123",
+        lastId = "run_abc456",
+        hasMore = false
+      )
+
+    // when
+    val givenResponse: Either[Exception, ListRunsResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given list run steps response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.ListRunStepsResponse._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonListRunStepsResponse
+    val expectedResponse: ListRunStepsResponse =
+      ListRunStepsResponse(
+        `object` = "list",
+        data = Seq(
+          RunStepData(
+            id = "step_abc123",
+            `object` = "thread.run.step",
+            createdAt = 1699063291,
+            runId = "run_abc123",
+            assistantId = "asst_abc123",
+            threadId = "thread_abc123",
+            `type` = "message_creation",
+            status = "completed",
+            cancelledAt = None,
+            completedAt = Some(1699063291),
+            expiredAt = None,
+            failedAt = None,
+            lastError = None,
+            stepDetails = MessageCreation(
+              messageId = "msg_abc123"
+            ),
+            usage = Some(
+              Usage(
+                promptTokens = 123,
+                completionTokens = 456,
+                totalTokens = 579
+              )
+            )
+          )
+        ),
+        firstId = "step_abc123",
+        lastId = "step_abc456",
+        hasMore = false
+      )
+
+    // when
+    val givenResponse: Either[Exception, ListRunStepsResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given retrieve run response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonRetrieveRunResponse
+    val expectedResponse: RunData =
+      RunData(
+        id = "run_abc123",
+        `object` = "thread.run",
+        createdAt = 1699075072,
+        assistantId = "asst_abc123",
+        threadId = "thread_abc123",
+        status = "completed",
+        startedAt = Some(1699075072),
+        expiresAt = None,
+        cancelledAt = None,
+        failedAt = None,
+        completedAt = Some(1699075073),
+        lastError = None,
+        model = "gpt-3.5-turbo",
+        instructions = None,
+        tools = Seq(CodeInterpreterTool),
+        fileIds = Seq("file-abc123", "file-abc456"),
+        metadata = Map.empty,
+        usage = Some(Usage(promptTokens = 123, completionTokens = 456, totalTokens = 579))
+      )
+
+    // when
+    val givenResponse: Either[Exception, RunData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given retrieve run step response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunStepData
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunStepData._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonRetrieveRunStepResponse
+    val expectedResponse: RunStepData =
+      RunStepData(
+        id = "step_abc123",
+        `object` = "thread.run.step",
+        createdAt = 1699063291,
+        runId = "run_abc123",
+        assistantId = "asst_abc123",
+        threadId = "thread_abc123",
+        `type` = "message_creation",
+        status = "completed",
+        cancelledAt = None,
+        completedAt = Some(1699063291),
+        expiredAt = None,
+        failedAt = None,
+        lastError = None,
+        stepDetails = MessageCreation(messageId = "msg_abc123"),
+        usage = Some(Usage(promptTokens = 123, completionTokens = 456, totalTokens = 579))
+      )
+
+    // when
+    val givenResponse: Either[Exception, RunStepData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given modify thread run request as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = ThreadRunsRequestBody.ModifyRun(
+      metadata = Map("user_id" -> "user_abc123")
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.ThreadRunsFixture.jsonModifyRunRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given modify run response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonModifyRunResponse
+    val expectedResponse: RunData =
+      RunData(
+        id = "run_abc123",
+        `object` = "thread.run",
+        createdAt = 1699075072,
+        assistantId = "asst_abc123",
+        threadId = "thread_abc123",
+        status = "completed",
+        startedAt = Some(1699075072),
+        expiresAt = None,
+        cancelledAt = None,
+        failedAt = None,
+        completedAt = Some(1699075073),
+        lastError = None,
+        model = "gpt-3.5-turbo",
+        instructions = None,
+        tools = Seq(CodeInterpreterTool),
+        fileIds = Seq("file-abc123", "file-abc456"),
+        metadata = Map("user_id" -> "user_abc123"),
+        usage = Some(Usage(promptTokens = 123, completionTokens = 456, totalTokens = 579))
+      )
+
+    // when
+    val givenResponse: Either[Exception, RunData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given submit tool outputs to run request as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = ThreadRunsRequestBody.SubmitToolOutputsToRun(
+      Seq(
+        ToolOutput(
+          toolCallId = Some("call_abc123"),
+          output = "28C"
+        )
+      )
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.ThreadRunsFixture.jsonSubmitToolOutputsRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+  "Given submit tool outputs to run response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonSubmitToolOutputsResponse
+    val expectedResponse: RunData =
+      RunData(
+        id = "run_abc123",
+        `object` = "thread.run",
+        createdAt = 1699075592,
+        assistantId = "asst_abc123",
+        threadId = "thread_abc123",
+        status = "queued",
+        startedAt = Some(1699075592),
+        expiresAt = Some(1699076192),
+        cancelledAt = None,
+        failedAt = None,
+        completedAt = None,
+        lastError = None,
+        model = "gpt-4",
+        instructions = Some("You tell the weather."),
+        tools = Seq(
+          FunctionTool(
+            name = "get_weather",
+            description = "Determine weather in my location",
+            parameters = Map(
+              "type" -> Str("object"),
+              "properties" -> Obj(
+                "location" -> Obj(
+                  "type" -> Str("string"),
+                  "description" -> Str("The city and state e.g. San Francisco, CA")
+                ),
+                "unit" -> Obj(
+                  "type" -> Str("string"),
+                  "enum" -> Arr(Str("c"), Str("f"))
+                )
+              ),
+              "required" -> Arr(Str("location"))
+            )
+          )
+        ),
+        fileIds = Seq.empty,
+        metadata = Map.empty,
+        usage = None
+      )
+
+    // when
+    val givenResponse: Either[Exception, RunData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given cancel run response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData
+    import sttp.openai.requests.threads.runs.ThreadRunsResponseData.RunData._
+
+    // given
+    val jsonResponse = fixtures.ThreadRunsFixture.jsonCancelRunResponse
+    val expectedResponse: RunData =
+      RunData(
+        id = "run_abc123",
+        `object` = "thread.run",
+        createdAt = 1699076126,
+        threadId = "thread_abc123",
+        assistantId = "asst_abc123",
+        status = "cancelling",
+        startedAt = Some(1699076126),
+        expiresAt = Some(1699076726),
+        cancelledAt = None,
+        failedAt = None,
+        completedAt = None,
+        lastError = None,
+        model = "gpt-4",
+        instructions = Some("You summarize books."),
+        tools = Seq(RetrievalTool),
+        fileIds = Seq.empty,
+        metadata = Map.empty,
+        usage = None
+      )
+
+    // when
+    val givenResponse: Either[Exception, RunData] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }}