Repository: softwaremill/sttp-openai
Pull request #340 (2025-05-21 19:24:39)

Add "strict" structured-output support for function calling
Tested it with the OpenAI API: `sbt "examples3/runMain examples.StrictStructuredOutputExample"`, seems to work.

Background on the feature: https://openai.com/index/introducing-structured-outputs-in-the-api/


Patch:
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/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()
+}


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
+  }
 }