Repository: softwaremill/sttp-openai
Pull request #243 (2024-12-07 15:29:16)

Fix `Choices` Deserialization
Some OpenAPI Compatible APIs like Ollama don't always return `lobprobs` as part of the `choices` JSON. To handle this we need to add a default argument to `logprobs` so upickle can handle this case. A private case class is used to avoid having to change the order of arguments which would be a breaking change.

adamw: Breaking changes are fine in 0.x versions. Also, maybe there's a some chance to write a test?
kapunga: > Breaking changes are fine in 0.x versions. Also, maybe there's a some chance to write a test?

Sounds good. I updated and added an `ollama` deserialization test that should also cover [PR 242](https://github.com/softwaremill/sttp-openai/pull/242) as well.
grzegorz-bielski: This would be useful to have.

Also, the `finishReason` in the `ChatChunkRequestResponseData.Choices` has the same problem. 



Patch:
diff --git a/core/src/main/scala/sttp/openai/requests/completions/CompletionsResponseData.scala b/core/src/main/scala/sttp/openai/requests/completions/CompletionsResponseData.scala
index 17ab88a..411410e 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/CompletionsResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/CompletionsResponseData.scala
@@ -7,8 +7,8 @@ object CompletionsResponseData {
   case class Choices(
       text: String,
       index: Int,
-      logprobs: Option[String],
-      finishReason: String
+      finishReason: String,
+      logprobs: Option[String] = None
   )
   object Choices {
     implicit val choicesR: SnakePickle.Reader[Choices] = SnakePickle.macroR[Choices]
diff --git a/core/src/main/scala/sttp/openai/requests/completions/Usage.scala b/core/src/main/scala/sttp/openai/requests/completions/Usage.scala
index 0343261..402256d 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/Usage.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/Usage.scala
@@ -17,8 +17,8 @@ case class Usage(
     promptTokens: Int,
     completionTokens: Int,
     totalTokens: Int,
-    completionTokensDetails: CompletionTokensDetails,
-    promptTokensDetails: PromptTokensDetails
+    completionTokensDetails: Option[CompletionTokensDetails] = None,
+    promptTokensDetails: Option[PromptTokensDetails] = None
 )
 
 object Usage {
diff --git a/examples/src/main/scala/examples/ChatProxy.scala b/examples/src/main/scala/examples/ChatProxy.scala
index e8cd31b..a21b178 100644
--- a/examples/src/main/scala/examples/ChatProxy.scala
+++ b/examples/src/main/scala/examples/ChatProxy.scala
@@ -1,4 +1,4 @@
-//> using dep com.softwaremill.sttp.openai::ox:0.2.4
+//> using dep com.softwaremill.sttp.openai::ox:0.2.6
 //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.7
 //> using dep com.softwaremill.sttp.client4::ox:4.0.0-M19
 //> using dep com.softwaremill.ox::core:0.5.11


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/CompletionsFixture.scala b/core/src/test/scala/sttp/openai/fixtures/CompletionsFixture.scala
index 3720625..7ae67dd 100644
--- a/core/src/test/scala/sttp/openai/fixtures/CompletionsFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/CompletionsFixture.scala
@@ -31,6 +31,27 @@ object CompletionsFixture {
                      |  }
                      |}""".stripMargin
 
+  /** Generated from: curl http://localhost:11434/v1/completions -d '{ "model": "llama3.2", "prompt": "Say Hello World as a haiku." }' */
+  val ollamaPromptResponse: String = """{
+                     |  "id": "cmpl-712",
+                     |  "object": "text_completion",
+                     |  "created": 1733664264,
+                     |  "model": "llama3.2",
+                     |  "system_fingerprint": "fp_ollama",
+                     |  "choices": [
+                     |    {
+                     |      "text": "Greeting coding dawn\n\"Hello, world!\" echoes bright\nProgramming's start",
+                     |      "index": 0,
+                     |      "finish_reason": "stop"
+                     |    }
+                     |  ],
+                     |  "usage": {
+                     |    "prompt_tokens": 33,
+                     |    "completion_tokens": 17,
+                     |    "total_tokens": 50
+                     |  }
+                     |}""".stripMargin
+
   val jsonMultiplePromptResponse: String = """{
                     |  "id":"cmpl-76D8UlnqOEkhVXu29nY7UPZFDTTlP",
                     |  "object":"text_completion",
diff --git a/core/src/test/scala/sttp/openai/requests/completions/CompletionsDataSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/CompletionsDataSpec.scala
index 81b87db..9f9dc3d 100644
--- a/core/src/test/scala/sttp/openai/requests/completions/CompletionsDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/completions/CompletionsDataSpec.scala
@@ -25,21 +25,57 @@ class CompletionsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
         Choices(
           text = "\n\nThis is indeed a test.",
           index = 0,
-          logprobs = None,
-          finishReason = "stop"
+          finishReason = "stop",
+          logprobs = None
         )
       ),
       usage = Usage(
         promptTokens = 5,
         completionTokens = 8,
         totalTokens = 13,
-        completionTokensDetails = CompletionTokensDetails(
-          acceptedPredictionTokens = 3,
-          audioTokens = 1,
-          reasoningTokens = 4,
-          rejectedPredictionTokens = 2
+        completionTokensDetails = Some(
+          CompletionTokensDetails(
+            acceptedPredictionTokens = 3,
+            audioTokens = 1,
+            reasoningTokens = 4,
+            rejectedPredictionTokens = 2
+          )
         ),
-        promptTokensDetails = PromptTokensDetails(audioTokens = 2, cachedTokens = 1)
+        promptTokensDetails = Some(PromptTokensDetails(audioTokens = 2, cachedTokens = 1))
+      )
+    )
+
+    // when
+    val givenResponse: Either[Exception, CompletionsResponse] = JsonUtils.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given ollama completions response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.completions.CompletionsResponseData._
+    import sttp.openai.requests.completions.CompletionsResponseData.CompletionsResponse._
+    import sttp.openai.requests.completions.CompletionsRequestBody.CompletionModel.CustomCompletionModel
+
+    // given
+    val jsonResponse = fixtures.CompletionsFixture.ollamaPromptResponse
+    val expectedResponse: CompletionsResponse = CompletionsResponse(
+      id = "cmpl-712",
+      `object` = "text_completion",
+      created = 1733664264,
+      model = CustomCompletionModel("llama3.2"),
+      choices = Seq(
+        Choices(
+          text = "Greeting coding dawn\n\"Hello, world!\" echoes bright\nProgramming's start",
+          index = 0,
+          finishReason = "stop",
+          logprobs = None
+        )
+      ),
+      usage = Usage(
+        promptTokens = 33,
+        completionTokens = 17,
+        totalTokens = 50
       )
     )
 
@@ -92,27 +128,29 @@ class CompletionsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
         Choices(
           text = "\n\nThis is indeed a test",
           index = 0,
-          logprobs = None,
-          finishReason = "length"
+          finishReason = "length",
+          logprobs = None
         ),
         Choices(
           text = "\n\nYes, this is also",
           index = 1,
-          logprobs = None,
-          finishReason = "length"
+          finishReason = "length",
+          logprobs = None
         )
       ),
       usage = Usage(
         promptTokens = 11,
         completionTokens = 14,
         totalTokens = 25,
-        completionTokensDetails = CompletionTokensDetails(
-          acceptedPredictionTokens = 3,
-          audioTokens = 1,
-          reasoningTokens = 4,
-          rejectedPredictionTokens = 2
+        completionTokensDetails = Some(
+          CompletionTokensDetails(
+            acceptedPredictionTokens = 3,
+            audioTokens = 1,
+            reasoningTokens = 4,
+            rejectedPredictionTokens = 2
+          )
         ),
-        promptTokensDetails = PromptTokensDetails(audioTokens = 2, cachedTokens = 1)
+        promptTokensDetails = Some(PromptTokensDetails(audioTokens = 2, cachedTokens = 1))
       )
     )
 
diff --git a/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala
index 88fcf4b..b39ad15 100644
--- a/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala
@@ -92,13 +92,15 @@ class ChatDataSpec extends AnyFlatSpec with Matchers with EitherValues {
       promptTokens = 10,
       completionTokens = 10,
       totalTokens = 20,
-      completionTokensDetails = CompletionTokensDetails(
-        acceptedPredictionTokens = 3,
-        audioTokens = 1,
-        reasoningTokens = 4,
-        rejectedPredictionTokens = 2
+      completionTokensDetails = Some(
+        CompletionTokensDetails(
+          acceptedPredictionTokens = 3,
+          audioTokens = 1,
+          reasoningTokens = 4,
+          rejectedPredictionTokens = 2
+        )
       ),
-      promptTokensDetails = PromptTokensDetails(audioTokens = 2, cachedTokens = 1)
+      promptTokensDetails = Some(PromptTokensDetails(audioTokens = 2, cachedTokens = 1))
     )
 
     val message: Message = Message(