Repository: softwaremill/sttp-openai
Pull request #324 (2025-04-16 00:09:04)

Update ox to 0.3.4
## About this PR
📦 Updates [com.softwaremill.sttp.openai:ox](https://github.com/softwaremill/sttp-openai) from `0.3.1` to `0.3.4`

📜 [GitHub Release Notes](https://github.com/softwaremill/sttp-openai/releases/tag/v0.3.4) - [Version Diff](https://github.com/softwaremill/sttp-openai/compare/v0.3.1...v0.3.4)

## Usage
✅ **Please merge!**

I'll automatically update this PR to resolve conflicts as long as you don't change it yourself.

If you'd like to skip this version, you can just close this PR. If you have any feedback, just mention me in the comments below.

Configure Scala Steward for your repository with a [`.scala-steward.conf`](https://github.com/scala-steward-org/scala-steward/blob/f0dfaa6d8e24b261aeafbc7f99c5325ed9365cf2/docs/repo-specific-configuration.md) file.

_Have a fantastic day writing Scala!_

<details>
<summary>⚙ Adjust future updates</summary>

Add this to your `.scala-steward.conf` file to ignore future updates of this dependency:
```
updates.ignore = [ { groupId = "com.softwaremill.sttp.openai", artifactId = "ox" } ]
```
Or, add this to slow down future updates of this dependency:
```
dependencyOverrides = [{
  pullRequests = { frequency = "30 days" },
  dependency = { groupId = "com.softwaremill.sttp.openai", artifactId = "ox" }
}]
```
</details>

<sup>
labels: library-update, early-semver-minor, semver-spec-patch, commit-count:1
</sup>

<!-- scala-steward = {
  "Update" : {
    "ForArtifactId" : {
      "crossDependency" : [
        {
          "groupId" : "com.softwaremill.sttp.openai",
          "artifactId" : {
            "name" : "ox",
            "maybeCrossName" : "ox_3"
          },
          "version" : "0.3.1",
          "sbtVersion" : null,
          "scalaVersion" : null,
          "configurations" : null
        },
        {
          "groupId" : "com.softwaremill.sttp.openai",
          "artifactId" : {
            "name" : "ox",
            "maybeCrossName" : "ox_3"
          },
          "version" : "0.3.1",
          "sbtVersion" : null,
          "scalaVersion" : null,
          "configurations" : "test"
        }
      ],
      "newerVersions" : [
        "0.3.4"
      ],
      "newerGroupId" : null,
      "newerArtifactId" : null
    }
  },
  "Labels" : [
    "library-update",
    "early-semver-minor",
    "semver-spec-patch",
    "commit-count:1"
  ]
} -->


Patch:
diff --git a/build.sbt b/build.sbt
index 7ac8895..279ecc7 100644
--- a/build.sbt
+++ b/build.sbt
@@ -3,7 +3,7 @@ import com.softwaremill.Publish.ossPublishSettings
 import Dependencies._
 
 val scala2 = List("2.13.16")
-val scala3 = List("3.3.5")
+val scala3 = List("3.3.6")
 
 def dependenciesFor(version: String)(deps: (Option[(Long, Long)] => ModuleID)*): Seq[ModuleID] =
   deps.map(_.apply(CrossVersion.partialVersion(version)))
@@ -96,7 +96,7 @@ lazy val examples = (projectMatrix in file("examples"))
   .settings(commonSettings)
   .settings(
     libraryDependencies ++= Seq(
-      "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % "1.11.23",
+      "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % "1.11.29",
       "ch.qos.logback" % "logback-classic" % "1.5.6"
     ) ++ Libraries.sttpClientOx,
     publish / skip := true
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
index 57dc02e..858ad46 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
@@ -3,34 +3,67 @@ package sttp.openai.requests.completions.chat.message
 import sttp.apispec.Schema
 import sttp.openai.json.SnakePickle
 import sttp.openai.requests.completions.chat.SchemaSupport
-import sttp.tapir.docs.apispec.schema.TapirSchemaToJsonSchema
 import sttp.tapir.{Schema => TSchema}
+import sttp.tapir.docs.apispec.schema.TapirSchemaToJsonSchema
 import ujson._
 
 sealed trait Tool
 
 object Tool {
 
-  /** @param description
+  /** Function tool definition – supports structured outputs by allowing the optional `strict` flag.
+    *
+    * @param description
     *   A description of what the function does, used by the model to choose when and how to call the function.
     * @param name
     *   The name of the function to be called. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64.
     * @param parameters
-    *   The parameters the functions accepts, described as a JSON Schema object
+    *   The parameters the functions accepts, described as a JSON Schema object.
+    * @param strict
+    *   When set to `true`, [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs) validation will be enforced by
+    *   the OpenAI API. Defaults to `None`, meaning the field is omitted in the outgoing JSON.
     */
-  case class FunctionTool(description: String, name: String, parameters: Map[String, Value]) extends Tool
+  case class FunctionTool(
+      description: String,
+      name: String,
+      parameters: Map[String, Value],
+      strict: Option[Boolean] = None
+  ) extends Tool
 
-  /** With this class we are not forced to provide Json Schema by hand. It will be automatically generated based on the type T. Please refer
-    * to readme for example of usage.
+  /** With this class we are not forced to provide Json Schema by hand. It will be automatically generated based on the type `T`. Please
+    * refer to readme for example of usage.
+    *
+    * @param description
+    *   A description of what the function does.
+    * @param name
+    *   The name of the function to be called.
+    * @param parameters
+    *   The Tapir derived [[sttp.apispec.Schema]] for the function parameters.
+    * @param strict
+    *   Optional strict flag (see [[FunctionTool.strict]]).
     */
-  case class SchematizedFunctionTool(description: String, name: String, parameters: Schema) extends Tool
+  case class SchematizedFunctionTool(
+      description: String,
+      name: String,
+      parameters: Schema,
+      strict: Option[Boolean] = None
+  ) extends Tool
 
   object SchematizedFunctionTool {
     def apply[T: TSchema](description: String, name: String): SchematizedFunctionTool =
       new SchematizedFunctionTool(
         description,
         name,
-        TapirSchemaToJsonSchema(implicitly[TSchema[T]], markOptionsAsNullable = true)
+        TapirSchemaToJsonSchema(implicitly[TSchema[T]], markOptionsAsNullable = true),
+        None
+      )
+
+    def apply[T: TSchema](description: String, name: String, strict: Option[Boolean]): SchematizedFunctionTool =
+      new SchematizedFunctionTool(
+        description,
+        name,
+        TapirSchemaToJsonSchema(implicitly[TSchema[T]], markOptionsAsNullable = true),
+        strict
       )
   }
 
@@ -39,20 +72,42 @@ object Tool {
   implicit val schematizedFunctionToolRW: SnakePickle.ReadWriter[SchematizedFunctionTool] = SnakePickle
     .readwriter[Value]
     .bimap[SchematizedFunctionTool](
-      functionTool =>
-        Obj(
+      functionTool => {
+        val obj = Obj(
           "description" -> functionTool.description,
           "name" -> functionTool.name,
           "parameters" -> SnakePickle.writeJs(functionTool.parameters)
-        ),
-      json => SchematizedFunctionTool(json("description").str, json("name").str, SnakePickle.read[Schema](json("parameters")))
+        )
+        functionTool.strict.foreach(flag => obj("strict") = Bool(flag))
+        obj
+      },
+      json => {
+        val strictOpt = json.obj.get("strict").map(_.bool)
+        SchematizedFunctionTool(
+          json("description").str,
+          json("name").str,
+          SnakePickle.read[Schema](json("parameters")),
+          strictOpt
+        )
+      }
     )
 
   implicit val functionToolRW: SnakePickle.ReadWriter[FunctionTool] = SnakePickle
     .readwriter[Value]
     .bimap[FunctionTool](
-      functionTool => Obj("description" -> functionTool.description, "name" -> functionTool.name, "parameters" -> functionTool.parameters),
-      json => FunctionTool(json("description").str, json("name").str, json("parameters").obj.toMap)
+      functionTool => {
+        val obj = Obj(
+          "description" -> functionTool.description,
+          "name" -> functionTool.name,
+          "parameters" -> functionTool.parameters
+        )
+        functionTool.strict.foreach(flag => obj("strict") = Bool(flag))
+        obj
+      },
+      json => {
+        val strictOpt = json.obj.get("strict").map(_.bool)
+        FunctionTool(json("description").str, json("name").str, json("parameters").obj.toMap, strictOpt)
+      }
     )
 
   /** Code interpreter tool
diff --git a/examples/src/main/scala/examples/ChatProxy.scala b/examples/src/main/scala/examples/ChatProxy.scala
index c4e99be..e2c02fe 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.3.1
+//> using dep com.softwaremill.sttp.openai::ox:0.3.4
 //> 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.13
diff --git a/examples/src/main/scala/examples/StrictStructuredFunctionCallingExample.scala b/examples/src/main/scala/examples/StrictStructuredFunctionCallingExample.scala
new file mode 100644
index 0000000..5035034
--- /dev/null
+++ b/examples/src/main/scala/examples/StrictStructuredFunctionCallingExample.scala
@@ -0,0 +1,76 @@
+package examples
+
+/** Manual playground for the strict structured outputs for function calling feature (`strict = true`).
+  *
+  * It sends the instruction "Say hello" but constrains the model with a JSON-Schema that requires the reply to be a number. When
+  * `strict = true` is set, the schema wins and the model must produce something that validates – hence we expect a number, not the word
+  * "hello".
+  *
+  * Run from the project root folder with: OPENAI_API_KEY=… sbt "examples3/runMain examples.StrictStructuredFunctionCallingExample"
+  */
+object StrictStructuredFunctionCallingExample extends App {
+  import ujson.{Arr, Bool, Obj, Str}
+  import sttp.client4.{DefaultSyncBackend, SyncBackend}
+  import sttp.openai.OpenAI
+  import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel, ResponseFormat}
+  import sttp.openai.requests.completions.chat.message.{Content, Message, ToolChoice}
+  import sttp.openai.requests.completions.chat.message.Tool.FunctionTool
+
+  val apiKey = sys.env.getOrElse("OPENAI_API_KEY", sys.error("OPENAI_API_KEY env variable not set"))
+
+  val getNumberTool = FunctionTool(
+    description = "Convert given text to upper-case",
+    name = "uppercase_text",
+    parameters = Map(
+      "type" -> Str("object"),
+      "properties" -> Obj(
+        "text" -> Obj("type" -> Str("number"))
+      ),
+      "required" -> Arr(Str("text")),
+      "additionalProperties" -> Bool(false)
+    ),
+    strict = Some(true)
+  )
+
+  val chatBody = ChatBody(
+    model = ChatCompletionModel.GPT4oMini,
+    messages = Seq(Message.UserMessage(Content.TextContent("Please uppercase the word 'hello'"))),
+    tools = Some(Seq(getNumberTool)),
+    toolChoice = Some(ToolChoice.ToolFunction("uppercase_text"))
+  )
+
+  val backend: SyncBackend = DefaultSyncBackend()
+  val openAI = new OpenAI(apiKey)
+
+  println("Sending request …")
+  val responseEither = openAI.createChatCompletion(chatBody).send(backend).body
+
+  responseEither match {
+    case Left(err) =>
+      System.err.println(s"OpenAI returned an error: $err")
+    case Right(resp) =>
+      val choice = resp.choices.head
+
+      val maybeArgsRaw: Option[String] = choice.message.toolCalls.collectFirst {
+        case sttp.openai.requests.completions.chat.ToolCall.FunctionToolCall(_, fn) => fn.arguments
+      }
+
+      maybeArgsRaw match {
+        case Some(jsonStr) =>
+          println(s"Function call arguments: $jsonStr")
+          val parsed = ujson.read(jsonStr)
+          val maybeNum = parsed.obj.get("text").flatMap(_.numOpt)
+
+          maybeNum match {
+            case Some(n) => println(s"Success, numeric value provided: $n")
+            case None    => println("Failure: arguments didn't contain numeric 'text' field.")
+          }
+
+        case None =>
+          println("Model did not return a function call. Full message: ")
+          println(choice.message)
+      }
+  }
+
+  backend.close()
+}
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index e2e2733..67fdb06 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -6,12 +6,12 @@ object Dependencies {
     val scalaTest = "3.2.19"
     val scalaTestCats = "1.6.0"
 
-    val sttpApispec = "0.11.8"
-    val sttpClient = "4.0.2"
+    val sttpApispec = "0.11.9"
+    val sttpClient = "4.0.7"
     val pekkoStreams = "1.1.3"
     val akkaStreams = "2.6.20"
-    val tapir = "1.11.23"
-    val uPickle = "4.1.0"
+    val tapir = "1.11.29"
+    val uPickle = "4.2.1"
   }
 
   object Libraries {
diff --git a/project/plugins.sbt b/project/plugins.sbt
index 48b9eac..a890fa1 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -1,5 +1,5 @@
-val sbtSoftwareMillVersion = "2.0.22"
+val sbtSoftwareMillVersion = "2.0.25"
 addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % sbtSoftwareMillVersion)
 addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % sbtSoftwareMillVersion)
-addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.10.1")
+addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.11.0")
 addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.7.1")


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/ToolFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ToolFixture.scala
index 018e01c..bc92e0c 100644
--- a/core/src/test/scala/sttp/openai/fixtures/ToolFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/ToolFixture.scala
@@ -50,4 +50,63 @@ object ToolFixture {
       |  }
       |}""".stripMargin
 
+  val jsonToolCallStrictTrue: String =
+    """{
+      |  "description": "Return greeting",
+      |  "name": "greet",
+      |  "parameters": {
+      |    "type": "object"
+      |  },
+      |  "strict": true
+      |}""".stripMargin
+
+  val jsonSchematizedToolCallStrictTrue: String =
+    """{
+      |  "description": "Books a flight for a passenger with full details",
+      |  "name": "book_flight",
+      |  "parameters": {
+      |    "additionalProperties": false,
+      |    "required": [
+      |      "passenger",
+      |      "departureCity",
+      |      "destinationCity"
+      |    ],
+      |    "$schema": "http://json-schema.org/draft/2020-12/schema#",
+      |    "$defs": {
+      |      "Passenger": {
+      |        "additionalProperties": false,
+      |        "required": [
+      |          "name",
+      |          "age"
+      |        ],
+      |        "title": "Passenger",
+      |        "type": "object",
+      |        "properties": {
+      |          "name": {
+      |            "type": "string"
+      |          },
+      |          "age": {
+      |            "type": "integer",
+      |            "format": "int32"
+      |          }
+      |        }
+      |      }
+      |    },
+      |    "title": "FlightDetails",
+      |    "type": "object",
+      |    "properties": {
+      |      "passenger": {
+      |        "$ref": "#/$defs/Passenger"
+      |      },
+      |      "departureCity": {
+      |        "type": "string"
+      |      },
+      |      "destinationCity": {
+      |        "type": "string"
+      |      }
+      |    }
+      |  },
+      |  "strict": true
+      |}""".stripMargin
+
 }
diff --git a/core/src/test/scala/sttp/openai/requests/completions/chat/ToolSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/chat/ToolSpec.scala
index 2cbfd3b..77da592 100644
--- a/core/src/test/scala/sttp/openai/requests/completions/chat/ToolSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/completions/chat/ToolSpec.scala
@@ -23,4 +23,45 @@ class ToolSpec extends AnyFlatSpec with Matchers with EitherValues {
     // then
     serializedToolCall shouldBe expectedJson
   }
+
+  "Given FunctionTool with strict flag" should "serialize and deserialize properly" in {
+    import sttp.openai.requests.completions.chat.message.Tool.FunctionTool
+    // given
+    val funcTool = FunctionTool(
+      description = "Return greeting",
+      name = "greet",
+      parameters = Map("type" -> ujson.Str("object")),
+      strict = Some(true)
+    )
+
+    val expectedJson = ujson.read(ToolFixture.jsonToolCallStrictTrue)
+
+    // when
+    val serialized = SnakePickle.writeJs(funcTool)
+    serialized shouldBe expectedJson
+
+    // and deserialization
+    val deserialized = SnakePickle.read[FunctionTool](expectedJson)
+    deserialized shouldBe funcTool
+  }
+
+  "Given SchematizedFunctionTool with strict flag" should "serialize and deserialize properly" in {
+    import sttp.tapir.generic.auto._
+    val tool = SchematizedFunctionTool[FlightDetails](
+      name = "book_flight",
+      description = "Books a flight for a passenger with full details",
+      strict = Some(true)
+    )
+
+    val expectedJson = ujson.read(ToolFixture.jsonSchematizedToolCallStrictTrue)
+
+    val serialized = SnakePickle.writeJs(tool)
+    serialized shouldBe expectedJson
+
+    val deserialized = SnakePickle.read[sttp.openai.requests.completions.chat.message.Tool.SchematizedFunctionTool](expectedJson)
+    // Ensure strict flag is preserved
+    deserialized.strict should contain(true)
+    // Ensure serialisation after deserialization matches expected JSON (round-trip)
+    SnakePickle.writeJs(deserialized) shouldBe expectedJson
+  }
 }