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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion core/src/main/scala/sttp/openai/OpenAI.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 => _, _}
Expand All @@ -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
Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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"
Expand Down
15 changes: 14 additions & 1 deletion core/src/main/scala/sttp/openai/OpenAISyncClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions core/src/test/scala/sttp/openai/fixtures/AudioFixture.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
Loading
Loading