Repository: softwaremill/sttp-openai
Pull request #138 (2023-11-24 03:03:07)

Remove null from streaming finishReason
Currently, initial response chunks from chat completion emits `null` at `choices[*].finishReason`:

```scala
ChatChunkResponse(chatcmpl-8OGfnT5mmJwiTTmICOgi26fOxrJnf,List(Choices(Delta(Some(Assistant),Some(),None,None),null,0)),1700794655,gpt-4-1106-preview,chat.completion.chunk,Some(fp_a24b4d720c))
<FAIL>: Die(java.lang.NullPointerException: Cannot invoke "String.length()" because the return value of "sttp.openai.requests.completions.chat.ChatChunkRequestResponseData$Choices.finishReason()" is null,Stack trace for thread "zio-fiber-":
)
```

This PR makes it optional.

guersam: Just noticed that `toolCalls.id` should be optional as well in streaming mode.

Patch:
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 29a0db8..30199be 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,7 @@ 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: Option[Seq[ToolCall]] = None, 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]
@@ -20,7 +20,7 @@ object ChatChunkRequestResponseData {
 
   case class Choices(
       delta: Delta,
-      finishReason: String,
+      finishReason: Option[String],
       index: Int
   )
 
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 8c04f08..78502b8 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
@@ -12,7 +12,7 @@ object ChatRequestResponseData {
     * @param functionCall
     *   The name and arguments of a function that should be called, as generated by the model.
     */
-  case class Message(role: Role, content: String, toolCalls: Seq[ToolCall], functionCall: Option[FunctionCall] = None)
+  case class Message(role: Role, content: String, toolCalls: Seq[ToolCall] = Nil, functionCall: Option[FunctionCall] = None)
 
   object Message {
     implicit val messageRW: SnakePickle.Reader[Message] = SnakePickle.macroR[Message]
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/FunctionCall.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/FunctionCall.scala
index b988dd2..16e7f4b 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/FunctionCall.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/FunctionCall.scala
@@ -9,7 +9,7 @@ import sttp.openai.json.SnakePickle
   * @param name
   *   The name of the function to call.
   */
-case class FunctionCall(arguments: String, name: String)
+case class FunctionCall(arguments: String, name: Option[String] = None)
 
 object FunctionCall {
   implicit val functionCallRW: SnakePickle.ReadWriter[FunctionCall] = SnakePickle.macroRW[FunctionCall]
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/ToolCall.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/ToolCall.scala
index bc94506..f4af7b1 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/ToolCall.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ToolCall.scala
@@ -12,14 +12,17 @@ object ToolCall {
     * @param function
     *   The function that the model called.
     */
-  case class FunctionToolCall(id: String, function: FunctionCall) extends ToolCall
+  case class FunctionToolCall(id: Option[String], function: FunctionCall) extends ToolCall
 
   implicit val functionToolCallRW: SnakePickle.ReadWriter[FunctionToolCall] = SnakePickle
     .readwriter[Value]
     .bimap[FunctionToolCall](
-      functionToolCall =>
-        Obj("type" -> "function", "id" -> functionToolCall.id, "function" -> SnakePickle.writeJs(functionToolCall.function)),
-      json => FunctionToolCall(json("id").str, SnakePickle.read[FunctionCall](json("function")))
+      functionToolCall => {
+        val baseObj = Obj("type" -> "function", "function" -> SnakePickle.writeJs(functionToolCall.function))
+        functionToolCall.id.foreach(baseObj("id") = _)
+        baseObj
+      },
+      json => FunctionToolCall(json.obj.get("id").map(_.str), SnakePickle.read[FunctionCall](json("function")))
     )
 
   implicit val toolCallRW: SnakePickle.ReadWriter[ToolCall] = SnakePickle
@@ -30,5 +33,4 @@ object ToolCall {
       },
       json => SnakePickle.read[FunctionToolCall](json)
     )
-
 }


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/ChatChunkFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ChatChunkFixture.scala
index 2b92693..1981724 100644
--- a/core/src/test/scala/sttp/openai/fixtures/ChatChunkFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/ChatChunkFixture.scala
@@ -40,11 +40,9 @@ object ChatChunkFixture {
       |      "name": "User",
       |      "tool_calls": [
       |        {
-      |          "id": "tool_id_1",
       |          "type": "function",
       |          "function": {
-      |            "arguments": "args",
-      |            "name": "Fish"
+      |            "arguments": "args"
       |          }
       |        },
       |        {
@@ -126,11 +124,9 @@ object ChatChunkFixture {
       |        "content": "  Hi",
       |        "tool_calls": [
       |         {
-      |           "id": "tool_id_1",
       |           "type": "function",
       |           "function": {
-      |             "arguments": "args",
-      |             "name": "Fish"
+      |             "arguments": "args"
       |           }
       |         },
       |         {
diff --git a/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala
index 0ffe150..25e5da9 100644
--- a/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala
@@ -40,11 +40,9 @@ object ChatFixture {
       |      "name": "User",
       |      "tool_calls": [
       |        {
-      |          "id": "tool_id_1",
       |          "type": "function",
       |          "function": {
-      |            "arguments": "args",
-      |            "name": "Fish"
+      |            "arguments": "args"
       |          }
       |        },
       |        {
@@ -135,11 +133,9 @@ object ChatFixture {
       |        "content": "Hi there! How can I assist you today?",
       |        "tool_calls": [
       |         {
-      |           "id": "tool_id_1",
       |           "type": "function",
       |           "function": {
-      |             "arguments": "args",
-      |             "name": "Fish"
+      |             "arguments": "args"
       |           }
       |         },
       |         {
diff --git a/core/src/test/scala/sttp/openai/requests/completions/chat/ChatChunkDataSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatChunkDataSpec.scala
index 08072cd..a24981c 100644
--- a/core/src/test/scala/sttp/openai/requests/completions/chat/ChatChunkDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatChunkDataSpec.scala
@@ -20,12 +20,12 @@ class ChatChunkDataSpec extends AnyFlatSpec with Matchers with EitherValues {
     val delta: Delta = Delta(
       role = Some(Role.Assistant),
       content = Some("  Hi"),
-      toolCalls = Some(toolCalls)
+      toolCalls = toolCalls
     )
 
     val choices: Choices = Choices(
       delta = delta,
-      finishReason = "stop",
+      finishReason = Some("stop"),
       index = 0
     )
 
diff --git a/core/src/test/scala/sttp/openai/utils/ChatCompletionFixtures.scala b/core/src/test/scala/sttp/openai/utils/ChatCompletionFixtures.scala
index e0b3fea..c9d934b 100644
--- a/core/src/test/scala/sttp/openai/utils/ChatCompletionFixtures.scala
+++ b/core/src/test/scala/sttp/openai/utils/ChatCompletionFixtures.scala
@@ -55,15 +55,20 @@ object ChatCompletionFixtures {
     Seq(function)
   }
 
-  def toolCalls: Seq[ToolCall] = {
-    val functionCall: FunctionCall = FunctionCall(
-      arguments = "args",
-      name = "Fish"
-    )
-
+  def toolCalls: Seq[ToolCall] =
     Seq(
-      FunctionToolCall("tool_id_1", functionCall),
-      FunctionToolCall("tool_id_2", functionCall)
+      FunctionToolCall(
+        None,
+        FunctionCall(
+          arguments = "args"
+        )
+      ),
+      FunctionToolCall(
+        Some("tool_id_2"),
+        FunctionCall(
+          arguments = "args",
+          name = Some("Fish")
+        )
+      )
     )
-  }
 }