Repository: softwaremill/sttp-openai (scala)

Issue:
Create embeddings
Given uri:
https://api.openai.com/v1/embeddings

Create sttp POST endpoint + tests

Docs reference
https://platform.openai.com/docs/api-reference/embeddings

Pull request description:
Closes #40

Patch:
diff --git a/core/src/main/scala/sttp/openai/OpenAi.scala b/core/src/main/scala/sttp/openai/OpenAi.scala
index 9ac6d9bc..c0634b3e 100644
--- a/core/src/main/scala/sttp/openai/OpenAi.scala
+++ b/core/src/main/scala/sttp/openai/OpenAi.scala
@@ -9,13 +9,15 @@ import sttp.openai.requests.completions.chat.ChatRequestBody.ChatBody
 import sttp.openai.requests.completions.chat.ChatRequestResponseData.ChatResponse
 import sttp.openai.requests.completions.edit.EditRequestBody.EditBody
 import sttp.openai.requests.completions.edit.EditRequestResponseData.EditResponse
+import sttp.openai.requests.embeddings.EmbeddingsRequestBody.EmbeddingsBody
+import sttp.openai.requests.embeddings.EmbeddingsResponseBody.EmbeddingResponse
 import sttp.openai.requests.files.FilesResponseData._
 import sttp.openai.requests.finetunes.FineTunesRequestBody
-import sttp.openai.requests.images.variations.ImageVariationsConfig
 import sttp.openai.requests.finetunes.FineTunesResponseData.{FineTuneResponse, GetFineTunesResponse}
 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.audio.AudioResponseData.AudioResponse
 import sttp.openai.requests.audio.transcriptions.TranscriptionConfig
@@ -417,6 +419,17 @@ class OpenAi(authToken: String) {
       .get(OpenAIEndpoints.FineTunesEndpoint)
       .response(asJsonSnake[GetFineTunesResponse])
 
+  /** @param embeddingsBody
+    *   Embeddings request body.
+    * @return
+    *   An embedding vector representing the input text.
+    */
+  def createEmbeddings(embeddingsBody: EmbeddingsBody) =
+    openApiAuthRequest
+      .post(OpenAIEndpoints.EmbeddingsEndpoint)
+      .body(embeddingsBody)
+      .response(asJsonSnake[EmbeddingResponse])
+
   /** @param fineTuneId
     *   The ID of the fine-tune job.
     * @return
@@ -439,6 +452,7 @@ private object OpenAIEndpoints {
   val CompletionsEndpoint: Uri = uri"https://api.openai.com/v1/completions"
   val CreateImageEndpoint: Uri = ImageEndpointBase.addPath("generations")
   val EditEndpoint: Uri = uri"https://api.openai.com/v1/edits"
+  val EmbeddingsEndpoint: Uri = uri"https://api.openai.com/v1/embeddings"
   val EditImageEndpoint: Uri = ImageEndpointBase.addPath("edits")
   val FilesEndpoint: Uri = uri"https://api.openai.com/v1/files"
   val FineTunesEndpoint: Uri = uri"https://api.openai.com/v1/fine-tunes"
diff --git a/core/src/main/scala/sttp/openai/requests/embeddings/EmbeddingsRequestBody.scala b/core/src/main/scala/sttp/openai/requests/embeddings/EmbeddingsRequestBody.scala
new file mode 100644
index 00000000..0872145c
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/embeddings/EmbeddingsRequestBody.scala
@@ -0,0 +1,30 @@
+package sttp.openai.requests.embeddings
+
+import sttp.openai.json.SnakePickle
+
+object EmbeddingsRequestBody {
+
+  /** @param model
+    *   ID of the model to use.
+    * @param input
+    *   Input text to get embeddings for, encoded as a string or array of tokens.
+    * @param user
+    *   A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse.
+    */
+  case class EmbeddingsBody(model: String, input: EmbeddingsInput, user: Option[String] = None)
+
+  object EmbeddingsBody {
+    implicit val embeddingsBodyWriter: SnakePickle.Writer[EmbeddingsBody] = SnakePickle.macroW
+  }
+
+  sealed trait EmbeddingsInput
+  object EmbeddingsInput {
+    case class SingleInput(value: String) extends EmbeddingsInput
+    case class MultipleInput(values: Seq[String]) extends EmbeddingsInput
+
+    implicit val embeddingsInputWriter: SnakePickle.Writer[EmbeddingsInput] = SnakePickle.writer[ujson.Value].comap[EmbeddingsInput] {
+      case SingleInput(value)    => SnakePickle.writeJs(value)
+      case MultipleInput(values) => SnakePickle.writeJs(values)
+    }
+  }
+}
diff --git a/core/src/main/scala/sttp/openai/requests/embeddings/EmbeddingsResponseBody.scala b/core/src/main/scala/sttp/openai/requests/embeddings/EmbeddingsResponseBody.scala
new file mode 100644
index 00000000..240b3887
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/embeddings/EmbeddingsResponseBody.scala
@@ -0,0 +1,33 @@
+package sttp.openai.requests.embeddings
+
+import sttp.openai.json.SnakePickle
+
+object EmbeddingsResponseBody {
+  case class EmbeddingData(
+      `object`: String,
+      index: Int,
+      embedding: Seq[Double]
+  )
+  object EmbeddingData {
+    implicit val embeddingDataReader: SnakePickle.Reader[EmbeddingData] = SnakePickle.macroR
+  }
+
+  case class EmbeddingResponse(
+      `object`: String,
+      data: Seq[EmbeddingData],
+      model: String,
+      usage: Usage
+  )
+
+  object EmbeddingResponse {
+    implicit val embeddingResponseDataReader: SnakePickle.Reader[EmbeddingResponse] = SnakePickle.macroR
+  }
+  case class Usage(
+      promptTokens: Int,
+      totalTokens: Int
+  )
+
+  object Usage {
+    implicit val usageDataReader: SnakePickle.Reader[Usage] = SnakePickle.macroR
+  }
+}


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/EmbeddingsFixture.scala b/core/src/test/scala/sttp/openai/fixtures/EmbeddingsFixture.scala
new file mode 100644
index 00000000..02a853d1
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/EmbeddingsFixture.scala
@@ -0,0 +1,25 @@
+package sttp.openai.fixtures
+
+object EmbeddingsFixture {
+  val jsonCreateEmbeddingsResponse = """{
+                                       |  "object": "list",
+                                       |  "data": [
+                                       |    {
+                                       |      "object": "embedding",
+                                       |      "index": 0,
+                                       |      "embedding": [
+                                       |        0.0023064255,
+                                       |        -0.009327292,
+                                       |        0.015797347,
+                                       |        -0.0077780345,
+                                       |        -0.0046922187
+                                       |        ]
+                                       |    }
+                                       |  ],
+                                       |  "model": "text-embedding-ada-002-v2",
+                                       |  "usage": {
+                                       |    "prompt_tokens": 8,
+                                       |    "total_tokens": 8
+                                       |  }
+                                       |}""".stripMargin
+}
diff --git a/core/src/test/scala/sttp/openai/requests/embeddings/EmbeddingsDataSpec.scala b/core/src/test/scala/sttp/openai/requests/embeddings/EmbeddingsDataSpec.scala
new file mode 100644
index 00000000..27fea11c
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/embeddings/EmbeddingsDataSpec.scala
@@ -0,0 +1,39 @@
+package sttp.openai.requests.embeddings
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.fixtures
+import sttp.openai.json.SttpUpickleApiExtension
+import EmbeddingsResponseBody._
+
+class EmbeddingsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+  "Given list files response as Json" should "be properly deserialized to case class" in {
+    // given
+    val listFilesResponse = fixtures.EmbeddingsFixture.jsonCreateEmbeddingsResponse
+    val expectedResponse = EmbeddingResponse(
+      `object` = "list",
+      data = Seq(
+        EmbeddingData(
+          `object` = "embedding",
+          index = 0,
+          embedding = Seq(
+            0.0023064255, -0.009327292, 0.015797347, -0.0077780345, -0.0046922187
+          )
+        )
+      ),
+      model = "text-embedding-ada-002-v2",
+      usage = Usage(
+        promptTokens = 8,
+        totalTokens = 8
+      )
+    )
+    // when
+    val givenResponse: Either[Exception, EmbeddingResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[EmbeddingResponse].apply(listFilesResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+}