Repository: softwaremill/sttp-openai
Pull request #292 (2025-03-07 06:06:48)

extend Audio & Models API's


Patch:
diff --git a/core/src/main/scala/sttp/openai/OpenAI.scala b/core/src/main/scala/sttp/openai/OpenAI.scala
index c9f237d..00afccc 100644
--- a/core/src/main/scala/sttp/openai/OpenAI.scala
+++ b/core/src/main/scala/sttp/openai/OpenAI.scala
@@ -10,6 +10,7 @@ import sttp.openai.requests.assistants.AssistantsRequestBody.{CreateAssistantBod
 import sttp.openai.requests.assistants.AssistantsResponseData.{AssistantData, DeleteAssistantResponse, ListAssistantsResponse}
 import sttp.openai.requests.audio.AudioResponseData.AudioResponse
 import sttp.openai.requests.audio.RecognitionModel
+import sttp.openai.requests.audio.speech.SpeechRequestBody
 import sttp.openai.requests.audio.transcriptions.TranscriptionConfig
 import sttp.openai.requests.audio.translations.TranslationConfig
 import sttp.openai.requests.batch.{QueryParameters => _, _}
@@ -32,7 +33,7 @@ import sttp.openai.requests.images.ImageResponseData.ImageResponse
 import sttp.openai.requests.images.creation.ImageCreationRequestBody.ImageCreationBody
 import sttp.openai.requests.images.edit.ImageEditsConfig
 import sttp.openai.requests.images.variations.ImageVariationsConfig
-import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
+import sttp.openai.requests.models.ModelsResponseData.{DeletedModelData, ModelData, ModelsResponse}
 import sttp.openai.requests.moderations.ModerationsRequestBody.ModerationsBody
 import sttp.openai.requests.moderations.ModerationsResponseData.ModerationData
 import sttp.openai.requests.threads.QueryParameters
@@ -81,6 +82,21 @@ class OpenAI(authToken: String, baseUri: Uri = OpenAIUris.OpenAIBaseUri) {
       .get(openAIUris.model(modelId))
       .response(asJson_parseErrors[ModelData])
 
+  /** Delete a fine-tuned model. You must have the Owner role in your organization to delete a model.
+    *
+    * [[https://platform.openai.com/docs/api-reference/models/delete]]
+    *
+    * @param modelId
+    *   The model to delete
+    *
+    * @return
+    *   Deletion status.
+    */
+  def deleteModel(modelId: String): Request[Either[OpenAIException, DeletedModelData]] =
+    openAIAuthRequest
+      .delete(openAIUris.model(modelId))
+      .response(asJson_parseErrors[DeletedModelData])
+
   /** Creates a completion for the provided prompt and parameters given in request body.
     *
     * [[https://platform.openai.com/docs/api-reference/completions/create]]
@@ -508,6 +524,43 @@ class OpenAI(authToken: String, baseUri: Uri = OpenAIUris.OpenAIBaseUri) {
       .get(openAIUris.fileContent(fileId))
       .response(asStringEither)
 
+  /** Generates audio from the input text.
+    *
+    * [[https://platform.openai.com/docs/api-reference/audio/createSpeech]]
+    *
+    * @param s
+    *   The streams implementation to use.
+    * @param requestBody
+    *   Request body that will be used to create a speech.
+    *
+    * @return
+    *   The audio file content.
+    */
+  def createSpeechAsBinaryStream[S](
+      s: Streams[S],
+      requestBody: SpeechRequestBody
+  ): StreamRequest[Either[OpenAIException, s.BinaryStream], S] =
+    openAIAuthRequest
+      .post(openAIUris.Speech)
+      .body(asJson(requestBody))
+      .response(asStreamUnsafe_parseErrors(s))
+
+  /** Generates audio from the input text.
+    *
+    * [[https://platform.openai.com/docs/api-reference/audio/createSpeech]]
+    *
+    * @param requestBody
+    *   Request body that will be used to create a speech.
+    *
+    * @return
+    *   The audio file content.
+    */
+  def createSpeechAsInputStream(requestBody: SpeechRequestBody): Request[Either[OpenAIException, InputStream]] =
+    openAIAuthRequest
+      .post(openAIUris.Speech)
+      .body(asJson(requestBody))
+      .response(asInputStreamUnsafe_parseErrors)
+
   /** Translates audio into English text.
     *
     * [[https://platform.openai.com/docs/api-reference/audio/create]]
@@ -1467,6 +1520,7 @@ private class OpenAIUris(val baseUri: Uri) {
   val AdminApiKeys: Uri = uri"$baseUri/organization/admin_api_keys"
   val Transcriptions: Uri = audioBase.addPath("transcriptions")
   val Translations: Uri = audioBase.addPath("translations")
+  val Speech: Uri = audioBase.addPath("speech")
   val VariationsImage: Uri = imageBase.addPath("variations")
 
   val Assistants: Uri = uri"$baseUri/assistants"
diff --git a/core/src/main/scala/sttp/openai/OpenAISyncClient.scala b/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
index 7a39fd2..d65478e 100644
--- a/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
+++ b/core/src/main/scala/sttp/openai/OpenAISyncClient.scala
@@ -30,7 +30,7 @@ import sttp.openai.requests.images.ImageResponseData.ImageResponse
 import sttp.openai.requests.images.creation.ImageCreationRequestBody.ImageCreationBody
 import sttp.openai.requests.images.edit.ImageEditsConfig
 import sttp.openai.requests.images.variations.ImageVariationsConfig
-import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
+import sttp.openai.requests.models.ModelsResponseData.{DeletedModelData, ModelData, ModelsResponse}
 import sttp.openai.requests.moderations.ModerationsRequestBody.ModerationsBody
 import sttp.openai.requests.moderations.ModerationsResponseData.ModerationData
 import sttp.openai.requests.threads.QueryParameters
@@ -80,6 +80,19 @@ class OpenAISyncClient private (
   def retrieveModel(modelId: String): ModelData =
     sendOrThrow(openAI.retrieveModel(modelId))
 
+  /** Delete a fine-tuned model. You must have the Owner role in your organization to delete a model.
+    *
+    * [[https://platform.openai.com/docs/api-reference/models/delete]]
+    *
+    * @param modelId
+    *   The model to delete
+    *
+    * @return
+    *   Deletion status.
+    */
+  def deleteModel(modelId: String): DeletedModelData =
+    sendOrThrow(openAI.deleteModel(modelId))
+
   /** Creates a completion for the provided prompt and parameters given in request body.
     *
     * [[https://platform.openai.com/docs/api-reference/completions/create]]
diff --git a/core/src/main/scala/sttp/openai/requests/audio/speech/SpeechRequestBody.scala b/core/src/main/scala/sttp/openai/requests/audio/speech/SpeechRequestBody.scala
new file mode 100644
index 0000000..80bbd17
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/audio/speech/SpeechRequestBody.scala
@@ -0,0 +1,77 @@
+package sttp.openai.requests.audio.speech
+
+import sttp.openai.json.SnakePickle
+
+/** Represents the request body for generating speech from text.
+  *
+  * @param model
+  *   One of the available TTS models: tts-1 or tts-1-hd.
+  * @param input
+  *   The text to generate audio for. The maximum length is 4096 characters.
+  * @param voice
+  *   The voice to use when generating the audio. Supported voices are alloy, ash, coral, echo, fable, onyx, nova, sage, and shimmer.
+  *   Previews of the voices are available in the Text to speech guide
+  *   [[https://platform.openai.com/docs/guides/text-to-speech#voice-options]].
+  * @param responseFormat
+  *   The format to audio in. Supported formats are mp3, opus, aac, flac, wav, and pcm. Defaults to mp3.
+  * @param speed
+  *   The speed of the generated audio. Select a value from 0.25 to 4.0. 1.0 is the default.
+  */
+case class SpeechRequestBody(
+    model: SpeechModel,
+    input: String,
+    voice: Voice,
+    responseFormat: Option[ResponseFormat] = None,
+    speed: Option[Float] = None
+)
+
+object SpeechRequestBody {
+  implicit val speechRequestBodyW: SnakePickle.Writer[SpeechRequestBody] = SnakePickle.macroW[SpeechRequestBody]
+}
+
+abstract sealed class SpeechModel(val value: String)
+
+object SpeechModel {
+  implicit val speechModelW: SnakePickle.Writer[SpeechModel] = SnakePickle
+    .writer[ujson.Value]
+    .comap[SpeechModel](_.value)
+
+  case object TTS1 extends SpeechModel("tts-1")
+  case object TTS1HD extends SpeechModel("tts-1-hd")
+  case class CustomSpeechModel(customValue: String) extends SpeechModel(customValue)
+}
+
+sealed abstract class Voice(val value: String)
+
+object Voice {
+  case object Alloy extends Voice("alloy")
+  case object Ash extends Voice("ash")
+  case object Coral extends Voice("coral")
+  case object Echo extends Voice("echo")
+  case object Fable extends Voice("fable")
+  case object Onyx extends Voice("onyx")
+  case object Nova extends Voice("nova")
+  case object Sage extends Voice("sage")
+  case object Shimmer extends Voice("shimmer")
+  case class CustomVoice(customVoice: String) extends Voice(customVoice)
+
+  implicit val voiceW: SnakePickle.Writer[Voice] = SnakePickle
+    .writer[ujson.Value]
+    .comap[Voice](_.value)
+}
+
+sealed abstract class ResponseFormat(val value: String)
+
+object ResponseFormat {
+  case object Mp3 extends ResponseFormat("mp3")
+  case object Opus extends ResponseFormat("opus")
+  case object Aac extends ResponseFormat("aac")
+  case object Flac extends ResponseFormat("flac")
+  case object Wav extends ResponseFormat("wav")
+  case object Pcm extends ResponseFormat("pcm")
+  case class CustomFormat(customFormat: String) extends ResponseFormat(customFormat)
+
+  implicit val formatW: SnakePickle.Writer[ResponseFormat] = SnakePickle
+    .writer[ujson.Value]
+    .comap[ResponseFormat](_.value)
+}
diff --git a/core/src/main/scala/sttp/openai/requests/models/ModelsResponseData.scala b/core/src/main/scala/sttp/openai/requests/models/ModelsResponseData.scala
index 34f8de3..1801fab 100644
--- a/core/src/main/scala/sttp/openai/requests/models/ModelsResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/models/ModelsResponseData.scala
@@ -4,6 +4,16 @@ import sttp.openai.json.SnakePickle
 
 object ModelsResponseData {
 
+  case class DeletedModelData(
+      id: String,
+      `object`: String,
+      deleted: Boolean
+  )
+
+  object DeletedModelData {
+    implicit val deletedModelDataR: SnakePickle.Reader[DeletedModelData] = SnakePickle.macroR[DeletedModelData]
+  }
+
   case class ModelData(
       id: String,
       `object`: String,
diff --git a/core/src/main/scala/sttp/openai/requests/upload/UploadResponse.scala b/core/src/main/scala/sttp/openai/requests/upload/UploadResponse.scala
index db98748..eb257aa 100644
--- a/core/src/main/scala/sttp/openai/requests/upload/UploadResponse.scala
+++ b/core/src/main/scala/sttp/openai/requests/upload/UploadResponse.scala
@@ -32,14 +32,14 @@ case class UploadResponse(
     purpose: String,
     status: String,
     expiresAt: Int,
-    file: Option[File]
+    file: Option[FileMetadata]
 )
 
 object UploadResponse {
   implicit val uploadResponseR: SnakePickle.Reader[UploadResponse] = SnakePickle.macroR[UploadResponse]
 }
 
-case class File(
+case class FileMetadata(
     id: String,
     `object`: String,
     bytes: Int,
@@ -48,8 +48,8 @@ case class File(
     purpose: String
 )
 
-object File {
-  implicit val fileR: SnakePickle.Reader[File] = SnakePickle.macroR[File]
+object FileMetadata {
+  implicit val fileR: SnakePickle.Reader[FileMetadata] = SnakePickle.macroR[FileMetadata]
 }
 
 /** Represents the response for an upload part.
diff --git a/streaming/akka/src/main/scala/sttp/openai/streaming/akka/package.scala b/streaming/akka/src/main/scala/sttp/openai/streaming/akka/package.scala
index 6a42624..c424e8b 100644
--- a/streaming/akka/src/main/scala/sttp/openai/streaming/akka/package.scala
+++ b/streaming/akka/src/main/scala/sttp/openai/streaming/akka/package.scala
@@ -10,6 +10,7 @@ import sttp.model.sse.ServerSentEvent
 import sttp.openai.OpenAI
 import sttp.openai.OpenAIExceptions.OpenAIException
 import sttp.openai.json.SttpUpickleApiExtension.deserializeJsonSnake
+import sttp.openai.requests.audio.speech.SpeechRequestBody
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
 import sttp.openai.requests.completions.chat.ChatRequestBody.ChatBody
 
@@ -18,6 +19,19 @@ package object akka {
 
   implicit class extension(val client: OpenAI) {
 
+    /** Generates audio from the input text.
+      *
+      * [[https://platform.openai.com/docs/api-reference/audio/createSpeech]]
+      *
+      * @param requestBody
+      *   Request body that will be used to create a speech.
+      *
+      * @return
+      *   The audio file content.
+      */
+    def createSpeech(requestBody: SpeechRequestBody): StreamRequest[Either[OpenAIException, Source[ByteString, Any]], AkkaStreams] =
+      client.createSpeechAsBinaryStream(AkkaStreams, requestBody)
+
     /** Creates and streams a model response as chunk objects for the given chat conversation defined in chatBody. The request will complete
       * and the connection close only once the source is fully consumed.
       *
diff --git a/streaming/fs2/src/main/scala/sttp/openai/streaming/fs2/package.scala b/streaming/fs2/src/main/scala/sttp/openai/streaming/fs2/package.scala
index d65579a..6f97848 100644
--- a/streaming/fs2/src/main/scala/sttp/openai/streaming/fs2/package.scala
+++ b/streaming/fs2/src/main/scala/sttp/openai/streaming/fs2/package.scala
@@ -9,6 +9,7 @@ import sttp.model.sse.ServerSentEvent
 import sttp.openai.OpenAI
 import sttp.openai.OpenAIExceptions.OpenAIException
 import sttp.openai.json.SttpUpickleApiExtension.deserializeJsonSnake
+import sttp.openai.requests.audio.speech.SpeechRequestBody
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
 import sttp.openai.requests.completions.chat.ChatRequestBody.ChatBody
 
@@ -17,6 +18,21 @@ package object fs2 {
 
   implicit class extension(val client: OpenAI) {
 
+    /** Generates audio from the input text.
+      *
+      * [[https://platform.openai.com/docs/api-reference/audio/createSpeech]]
+      *
+      * @param requestBody
+      *   Request body that will be used to create a speech.
+      *
+      * @return
+      *   The audio file content.
+      */
+    def createSpeech[F[_]: RaiseThrowable](
+        requestBody: SpeechRequestBody
+    ): StreamRequest[Either[OpenAIException, Stream[F, Byte]], Fs2Streams[F]] =
+      client.createSpeechAsBinaryStream(Fs2Streams[F], requestBody)
+
     /** Creates and streams a model response as chunk objects for the given chat conversation defined in chatBody. The request will complete
       * and the connection close only once the source is fully consumed.
       *
diff --git a/streaming/pekko/src/main/scala/sttp/openai/streaming/pekko/package.scala b/streaming/pekko/src/main/scala/sttp/openai/streaming/pekko/package.scala
index 07cb958..16b9ce9 100644
--- a/streaming/pekko/src/main/scala/sttp/openai/streaming/pekko/package.scala
+++ b/streaming/pekko/src/main/scala/sttp/openai/streaming/pekko/package.scala
@@ -10,6 +10,7 @@ import sttp.model.sse.ServerSentEvent
 import sttp.openai.OpenAI
 import sttp.openai.OpenAIExceptions.OpenAIException
 import sttp.openai.json.SttpUpickleApiExtension.deserializeJsonSnake
+import sttp.openai.requests.audio.speech.SpeechRequestBody
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
 import sttp.openai.requests.completions.chat.ChatRequestBody.ChatBody
 
@@ -18,6 +19,19 @@ package object pekko {
 
   implicit class extension(val client: OpenAI) {
 
+    /** Generates audio from the input text.
+      *
+      * [[https://platform.openai.com/docs/api-reference/audio/createSpeech]]
+      *
+      * @param requestBody
+      *   Request body that will be used to create a speech.
+      *
+      * @return
+      *   The audio file content.
+      */
+    def createSpeech(requestBody: SpeechRequestBody): StreamRequest[Either[OpenAIException, Source[ByteString, Any]], PekkoStreams] =
+      client.createSpeechAsBinaryStream(PekkoStreams, requestBody)
+
     /** Creates and streams a model response as chunk objects for the given chat conversation defined in chatBody. The request will complete
       * and the connection close only once the source is fully consumed.
       *
diff --git a/streaming/zio/src/main/scala/sttp/openai/streaming/zio/package.scala b/streaming/zio/src/main/scala/sttp/openai/streaming/zio/package.scala
index 557271d..ebaf7b4 100644
--- a/streaming/zio/src/main/scala/sttp/openai/streaming/zio/package.scala
+++ b/streaming/zio/src/main/scala/sttp/openai/streaming/zio/package.scala
@@ -10,6 +10,7 @@ import sttp.model.sse.ServerSentEvent
 import sttp.openai.OpenAI
 import sttp.openai.OpenAIExceptions.OpenAIException
 import sttp.openai.json.SttpUpickleApiExtension.deserializeJsonSnake
+import sttp.openai.requests.audio.speech.SpeechRequestBody
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
 import sttp.openai.requests.completions.chat.ChatRequestBody.ChatBody
 
@@ -18,6 +19,19 @@ package object zio {
 
   implicit class extension(val client: OpenAI) {
 
+    /** Generates audio from the input text.
+      *
+      * [[https://platform.openai.com/docs/api-reference/audio/createSpeech]]
+      *
+      * @param requestBody
+      *   Request body that will be used to create a speech.
+      *
+      * @return
+      *   The audio file content.
+      */
+    def createSpeech(requestBody: SpeechRequestBody): StreamRequest[Either[OpenAIException, Stream[Throwable, Byte]], ZioStreams] =
+      client.createSpeechAsBinaryStream(ZioStreams, requestBody)
+
     /** Creates and streams a model response as chunk objects for the given chat conversation defined in chatBody. The request will complete
       * and the connection close only once the source is fully consumed.
       *


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/AudioFixture.scala b/core/src/test/scala/sttp/openai/fixtures/AudioFixture.scala
index 13ac96c..0adef3e 100644
--- a/core/src/test/scala/sttp/openai/fixtures/AudioFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/AudioFixture.scala
@@ -4,4 +4,12 @@ object AudioFixture {
   val jsonResponse: String = """{
       | "text": "Imagine the wildest idea that you've ever had, and you're curious about how it might scale to something that's a 100, a 1,000 times bigger. This is a place where you can get to do that."
       |}""".stripMargin
+
+  val jsonCreateSpeechRequest: String = """{
+      | "model": "tts-1",
+      | "input": "Hello, my name is John.",
+      | "voice": "alloy",
+      | "response_format": "mp3",
+      | "speed": 1.0
+      |}""".stripMargin
 }
diff --git a/core/src/test/scala/sttp/openai/requests/audio/speech/SpeechDataSpec.scala b/core/src/test/scala/sttp/openai/requests/audio/speech/SpeechDataSpec.scala
new file mode 100644
index 0000000..37a40b9
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/audio/speech/SpeechDataSpec.scala
@@ -0,0 +1,28 @@
+package sttp.openai.requests.audio.speech
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.fixtures.AudioFixture
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.audio.speech.SpeechModel.TTS1
+
+class SpeechDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+
+  "Given create fine tuning job request as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest = SpeechRequestBody(
+      model = TTS1,
+      input = "Hello, my name is John.",
+      voice = Voice.Alloy,
+      responseFormat = Some(ResponseFormat.Mp3),
+      speed = Some(1.0f)
+    )
+    val jsonRequest: ujson.Value = ujson.read(AudioFixture.jsonCreateSpeechRequest)
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+}
diff --git a/core/src/test/scala/sttp/openai/requests/models/ModelsGetResponseDataSpec.scala b/core/src/test/scala/sttp/openai/requests/models/ModelsGetResponseDataSpec.scala
index 7e1c49c..ce96912 100644
--- a/core/src/test/scala/sttp/openai/requests/models/ModelsGetResponseDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/models/ModelsGetResponseDataSpec.scala
@@ -5,11 +5,30 @@ import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 import sttp.openai.fixtures
 import sttp.openai.requests.models.ModelsResponseData.ModelsResponse._
-import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelPermission, ModelsResponse}
+import sttp.openai.requests.models.ModelsResponseData.{DeletedModelData, ModelData, ModelPermission, ModelsResponse}
 import sttp.openai.utils.JsonUtils
 
 class ModelsGetResponseDataSpec extends AnyFlatSpec with Matchers with EitherValues {
 
+  "Given deleted model response as Json" should "be properly deserialized to case class" in {
+    import ModelsResponseData.DeletedModelData._
+    // given
+    val response: String = """{
+                             |  "id": "ft:gpt-4o-mini:acemeco:suffix:abc123",
+                             |  "object": "model",
+                             |  "deleted": true
+                             |}""".stripMargin
+    val expectedResponse: DeletedModelData = DeletedModelData(
+      id = "ft:gpt-4o-mini:acemeco:suffix:abc123",
+      `object` = "model",
+      deleted = true
+    )
+    // when
+    val givenResponse: Either[Exception, DeletedModelData] = JsonUtils.deserializeJsonSnake[DeletedModelData].apply(response)
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
   "Given models response as Json" should "be properly deserialized to case class" in {
 
     // given
diff --git a/core/src/test/scala/sttp/openai/requests/upload/UploadDataSpec.scala b/core/src/test/scala/sttp/openai/requests/upload/UploadDataSpec.scala
index 07960b8..8ea8118 100644
--- a/core/src/test/scala/sttp/openai/requests/upload/UploadDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/upload/UploadDataSpec.scala
@@ -49,7 +49,7 @@ class UploadDataSpec extends AnyFlatSpec with Matchers with EitherValues {
       status = "completed",
       expiresAt = 1719127296,
       file = Some(
-        File(
+        FileMetadata(
           id = "file-xyz321",
           bytes = 1147483648,
           createdAt = 1719186911,
diff --git a/streaming/akka/src/test/scala/sttp/openai/streaming/akka/AkkaClientSpec.scala b/streaming/akka/src/test/scala/sttp/openai/streaming/akka/AkkaClientSpec.scala
index f4f7b5c..79a0dab 100644
--- a/streaming/akka/src/test/scala/sttp/openai/streaming/akka/AkkaClientSpec.scala
+++ b/streaming/akka/src/test/scala/sttp/openai/streaming/akka/AkkaClientSpec.scala
@@ -14,6 +14,8 @@ import sttp.openai.OpenAI
 import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
 import sttp.openai.fixtures.ErrorFixture
 import sttp.openai.json.SnakePickle._
+import sttp.openai.requests.audio.speech.SpeechModel.TTS1
+import sttp.openai.requests.audio.speech.{SpeechRequestBody, Voice}
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse.DoneEvent
 import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
@@ -22,6 +24,26 @@ import sttp.openai.utils.JsonUtils.compactJson
 class AkkaClientSpec extends AsyncFlatSpec with Matchers with EitherValues {
   implicit val system: ActorSystem = ActorSystem()
 
+  "Creating speech" should "return byte stream" in {
+    // given
+    val expectedResponse = "audio content"
+    val akkaBackendStub = AkkaHttpBackend.stub.whenAnyRequest.thenRespond(ResponseStub.adjust(Source(ByteString(expectedResponse))))
+    val client = new OpenAI(authToken = "test-token")
+    val givenRequest = SpeechRequestBody(
+      model = TTS1,
+      input = "Hello, my name is John.",
+      voice = Voice.Alloy
+    )
+    // when
+    val response = client
+      .createSpeech(givenRequest)
+      .send(akkaBackendStub)
+      .map(_.body.value)
+      .flatMap(_.runWith(Sink.seq))
+    // then
+    response.map(_ shouldBe expectedResponse.getBytes.toSeq)
+  }
+
   for ((statusCode, expectedError) <- ErrorFixture.testData)
     s"Service response with status code: $statusCode" should s"return properly deserialized ${expectedError.getClass.getSimpleName}" in {
       // given
diff --git a/streaming/fs2/src/test/scala/sttp/openai/streaming/fs2/Fs2ClientSpec.scala b/streaming/fs2/src/test/scala/sttp/openai/streaming/fs2/Fs2ClientSpec.scala
index 8492268..90c4d9f 100644
--- a/streaming/fs2/src/test/scala/sttp/openai/streaming/fs2/Fs2ClientSpec.scala
+++ b/streaming/fs2/src/test/scala/sttp/openai/streaming/fs2/Fs2ClientSpec.scala
@@ -13,12 +13,35 @@ import sttp.openai.OpenAI
 import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
 import sttp.openai.fixtures.ErrorFixture
 import sttp.openai.json.SnakePickle._
+import sttp.openai.requests.audio.speech.SpeechModel.TTS1
+import sttp.openai.requests.audio.speech.{SpeechRequestBody, Voice}
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse.DoneEvent
 import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
 import sttp.openai.utils.JsonUtils.compactJson
 
 class Fs2ClientSpec extends AsyncFlatSpec with AsyncIOSpec with Matchers with EitherValues {
+  "Creating speech" should "return byte stream" in {
+    // given
+    val expectedResponse = "audio content"
+    val streamedResponse = Stream.emit(expectedResponse).through(text.utf8.encode).covary[IO]
+    val fs2BackendStub = HttpClientFs2Backend.stub[IO].whenAnyRequest.thenRespond(ResponseStub.adjust(streamedResponse))
+    val client = new OpenAI(authToken = "test-token")
+    val givenRequest = SpeechRequestBody(
+      model = TTS1,
+      input = "Hello, my name is John.",
+      voice = Voice.Alloy
+    )
+    // when
+    val response = client
+      .createSpeech[IO](givenRequest)
+      .send(fs2BackendStub)
+      .map(_.body.value)
+      .flatMap(_.compile.toList)
+    // then
+    response.asserting(_ shouldBe expectedResponse.getBytes.toSeq)
+  }
+
   for ((statusCode, expectedError) <- ErrorFixture.testData)
     s"Service response with status code: $statusCode" should s"return properly deserialized ${expectedError.getClass.getSimpleName}" in {
       // given
diff --git a/streaming/pekko/src/test/scala/sttp/openai/streaming/pekko/PekkoClientSpec.scala b/streaming/pekko/src/test/scala/sttp/openai/streaming/pekko/PekkoClientSpec.scala
index c644207..da2b5dd 100644
--- a/streaming/pekko/src/test/scala/sttp/openai/streaming/pekko/PekkoClientSpec.scala
+++ b/streaming/pekko/src/test/scala/sttp/openai/streaming/pekko/PekkoClientSpec.scala
@@ -14,6 +14,8 @@ import sttp.openai.OpenAI
 import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
 import sttp.openai.fixtures.ErrorFixture
 import sttp.openai.json.SnakePickle._
+import sttp.openai.requests.audio.speech.SpeechModel.TTS1
+import sttp.openai.requests.audio.speech.{SpeechRequestBody, Voice}
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse.DoneEvent
 import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
@@ -22,6 +24,26 @@ import sttp.openai.utils.JsonUtils.compactJson
 class PekkoClientSpec extends AsyncFlatSpec with Matchers with EitherValues {
   implicit val system: ActorSystem = ActorSystem()
 
+  "Creating speech" should "return byte stream" in {
+    // given
+    val expectedResponse = "audio content"
+    val pekkoBackendStub = PekkoHttpBackend.stub.whenAnyRequest.thenRespond(ResponseStub.adjust(Source(ByteString(expectedResponse))))
+    val client = new OpenAI(authToken = "test-token")
+    val givenRequest = SpeechRequestBody(
+      model = TTS1,
+      input = "Hello, my name is John.",
+      voice = Voice.Alloy
+    )
+    // when
+    val response = client
+      .createSpeech(givenRequest)
+      .send(pekkoBackendStub)
+      .map(_.body.value)
+      .flatMap(_.runWith(Sink.seq))
+    // then
+    response.map(_ shouldBe expectedResponse.getBytes.toSeq)
+  }
+
   for ((statusCode, expectedError) <- ErrorFixture.testData)
     s"Service response with status code: $statusCode" should s"return properly deserialized ${expectedError.getClass.getSimpleName}" in {
       // given
diff --git a/streaming/zio/src/test/scala/sttp/openai/streaming/zio/ZioClientSpec.scala b/streaming/zio/src/test/scala/sttp/openai/streaming/zio/ZioClientSpec.scala
index 26741d5..c2b8889 100644
--- a/streaming/zio/src/test/scala/sttp/openai/streaming/zio/ZioClientSpec.scala
+++ b/streaming/zio/src/test/scala/sttp/openai/streaming/zio/ZioClientSpec.scala
@@ -9,6 +9,8 @@ import sttp.model.sse.ServerSentEvent
 import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
 import sttp.openai.fixtures.ErrorFixture
 import sttp.openai.json.SnakePickle._
+import sttp.openai.requests.audio.speech.SpeechModel.TTS1
+import sttp.openai.requests.audio.speech.{SpeechRequestBody, Voice}
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse.DoneEvent
 import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
@@ -20,6 +22,28 @@ import zio.stream._
 class ZioClientSpec extends AnyFlatSpec with Matchers with EitherValues {
   private val runtime: Runtime[Any] = Runtime.default
 
+  "Creating speech" should "return byte stream" in {
+    // given
+    val expectedResponse = "audio content"
+    val streamedResponse = ZStream(expectedResponse).via(ZPipeline.utf8Encode)
+    val zioBackendStub = HttpClientZioBackend.stub.whenAnyRequest.thenRespond(ResponseStub.adjust(streamedResponse))
+    val client = new OpenAI(authToken = "test-token")
+    val givenRequest = SpeechRequestBody(
+      model = TTS1,
+      input = "Hello, my name is John.",
+      voice = Voice.Alloy
+    )
+    // when
+    val responseEffect = client
+      .createSpeech(givenRequest)
+      .send(zioBackendStub)
+      .map(_.body.value)
+      .flatMap(_.runCollect)
+    val response = unsafeRun(responseEffect)
+    // then
+    response.toList shouldBe expectedResponse.getBytes.toSeq
+  }
+
   for ((statusCode, expectedError) <- ErrorFixture.testData)
     s"Service response with status code: $statusCode" should s"return properly deserialized ${expectedError.getClass.getSimpleName}" in {
       // given