Skip to content

Commit 45ae00f

Browse files
authored
Add typed createChatCompletion method for structured response handling (#453)
1 parent 9710372 commit 45ae00f

File tree

3 files changed

+161
-0
lines changed

3 files changed

+161
-0
lines changed

openai/src/main/scala/sttp/ai/openai/OpenAISyncClient.scala

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package sttp.ai.openai
33
import sttp.client4.{DefaultSyncBackend, Request, SyncBackend}
44
import sttp.model.Uri
55
import sttp.ai.openai.OpenAIExceptions.OpenAIException
6+
import sttp.ai.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
67
import sttp.ai.openai.config.OpenAIConfig
78
import sttp.ai.openai.requests.admin.{QueryParameters => _, _}
89
import sttp.ai.openai.requests.assistants.AssistantsRequestBody.{CreateAssistantBody, ModifyAssistantBody}
@@ -14,6 +15,7 @@ import sttp.ai.openai.requests.batch.{BatchRequestBody, BatchResponse, ListBatch
1415
import sttp.ai.openai.requests.completions.CompletionsRequestBody.CompletionsBody
1516
import sttp.ai.openai.requests.completions.CompletionsResponseData.CompletionsResponse
1617
import sttp.ai.openai.requests.completions.chat
18+
import sttp.ai.openai.requests.completions.chat.ChatRequestBody.ResponseFormat.JsonSchema
1719
import sttp.ai.openai.requests.completions.chat.ChatRequestBody.{ChatBody, UpdateChatCompletionRequestBody}
1820
import sttp.ai.openai.requests.completions.chat.ChatRequestResponseData.{
1921
ChatResponse,
@@ -51,6 +53,7 @@ import sttp.ai.openai.requests.vectorstore.file.VectorStoreFileResponseData.{
5153
VectorStoreFile
5254
}
5355
import sttp.ai.openai.requests.{admin, batch, finetuning}
56+
import sttp.tapir.{Schema => TapirSchema}
5457

5558
import java.io.File
5659

@@ -199,6 +202,39 @@ class OpenAISyncClient private (
199202
def createChatCompletion(chatBody: ChatBody): ChatResponse =
200203
sendOrThrow(openAI.createChatCompletion(chatBody))
201204

205+
/** Creates a typed model response for the given chat conversation and response format defined in chatBody.
206+
*
207+
* @param chatBody
208+
* Chat request body.
209+
* @param responseName
210+
* An optional name for the response.
211+
* @param parseFunction
212+
* Function that parse the response message to a given object or an error message in case of failure. Use Either to treat failure as
213+
* checked error instead of unchecked Exception.
214+
* @tparam T
215+
* The return type, which must have a TapirSchema instance available.
216+
*/
217+
def createChatCompletion[T: TapirSchema](chatBody: ChatBody, responseName: Option[String] = None)(
218+
parseFunction: String => Either[String, T]
219+
): T = {
220+
val withResponseFormat =
221+
if (chatBody.responseFormat.nonEmpty) chatBody
222+
else chatBody.copy(responseFormat = Some(JsonSchema.withTapirSchema[T](responseName.getOrElse("response"), None, Some(true))))
223+
224+
val finalRes: Either[OpenAIException, T] = for {
225+
res <- customizeRequest.apply(openAI.createChatCompletion(withResponseFormat)).send(backend).body
226+
message <- res.choices.headOption
227+
.map(_.message)
228+
.toRight(new DeserializationOpenAIException("no choices found in response", null))
229+
content <- parseFunction(message.content).left.map(err => new DeserializationOpenAIException(err, null))
230+
} yield content
231+
232+
finalRes match {
233+
case Right(value) => value
234+
case Left(exception) => throw exception
235+
}
236+
}
237+
202238
/** Get a stored chat completion. Only chat completions that have been created with the store parameter set to true will be returned.
203239
*
204240
* [[https://platform.openai.com/docs/api-reference/chat/get]]

openai/src/test/scala/sttp/ai/openai/openai/client/SyncClientSpec.scala

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ import sttp.client4.testing.ResponseStub
88
import sttp.model.StatusCode
99
import sttp.model.StatusCode._
1010
import sttp.ai.openai.OpenAIExceptions.OpenAIException
11+
import sttp.ai.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
1112
import sttp.ai.openai.fixtures.ErrorFixture
13+
import sttp.ai.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
1214
import sttp.ai.openai.requests.models.ModelsResponseData._
15+
import sttp.ai.openai.requests.responses.ResponsesModel.GPT4oMini
1316
import sttp.ai.openai.{CustomizeOpenAIRequest, OpenAISyncClient}
17+
import sttp.tapir.Schema
1418

1519
import java.util.concurrent.atomic.AtomicReference
1620

@@ -79,4 +83,80 @@ class SyncClientSpec extends AnyFlatSpec with Matchers with EitherValues {
7983
capturedRequest.get().headers.find(_.is("X-Test")).map(_.value) shouldBe Some("test"): Unit
8084
capturedRequest.get().headers.find(_.is("X-Test-2")).map(_.value) shouldBe Some("test-2")
8185
}
86+
87+
case class Step(explanation: String, output: String)
88+
89+
case class MathReasoning(steps: List[Step], finalAnswer: String)
90+
91+
"typed createChatCompletion" should "be ok" in {
92+
// given
93+
val capturedRequest = new AtomicReference[GenericRequest[_, _]](null)
94+
val syncBackendStub = DefaultSyncBackend.stub.whenAnyRequest.thenRespondF { request =>
95+
capturedRequest.set(request)
96+
ResponseStub.adjust(sttp.ai.openai.fixtures.CompletionsFixture.structuredOutputsResponse, StatusCode.Ok)
97+
}
98+
val syncClient = OpenAISyncClient(authToken = "test-token", backend = syncBackendStub)
99+
100+
// when
101+
val mockRes = MathReasoning(Nil, "final answer")
102+
import sttp.tapir.generic.auto._
103+
val res = syncClient.createChatCompletion[MathReasoning](ChatBody(Nil, ChatCompletionModel.GPT4oMini)) { body =>
104+
Right(mockRes)
105+
}
106+
107+
// then
108+
res shouldBe mockRes
109+
}
110+
111+
"typed createChatCompletion" should "throw exception with parsed error" in {
112+
// given
113+
val capturedRequest = new AtomicReference[GenericRequest[_, _]](null)
114+
val syncBackendStub = DefaultSyncBackend.stub.whenAnyRequest.thenRespondF { request =>
115+
capturedRequest.set(request)
116+
ResponseStub.adjust(sttp.ai.openai.fixtures.CompletionsFixture.structuredOutputsResponse, StatusCode.Ok)
117+
}
118+
val syncClient = OpenAISyncClient(authToken = "test-token", backend = syncBackendStub)
119+
120+
// when
121+
import sttp.tapir.generic.auto._
122+
val caught =
123+
intercept[OpenAIException](syncClient.createChatCompletion[MathReasoning](ChatBody(Nil, ChatCompletionModel.GPT4oMini)) { body =>
124+
Left("parsed error")
125+
})
126+
127+
val expectedError = new DeserializationOpenAIException("parsed error", null)
128+
caught.getClass shouldBe expectedError.getClass: Unit
129+
caught.message shouldBe expectedError.message: Unit
130+
caught.cause shouldBe null
131+
caught.code shouldBe expectedError.code: Unit
132+
caught.param shouldBe expectedError.param: Unit
133+
caught.`type` shouldBe expectedError.`type`
134+
}
135+
136+
"typed createChatCompletion" should "throw exception without choices" in {
137+
// given
138+
val capturedRequest = new AtomicReference[GenericRequest[_, _]](null)
139+
val syncBackendStub = DefaultSyncBackend.stub.whenAnyRequest.thenRespondF { request =>
140+
capturedRequest.set(request)
141+
ResponseStub.adjust(sttp.ai.openai.fixtures.CompletionsFixture.structuredOutputsResponseWithoutChoices, StatusCode.Ok)
142+
}
143+
val syncClient = OpenAISyncClient(authToken = "test-token", backend = syncBackendStub)
144+
145+
// when
146+
val mockRes = MathReasoning(Nil, "final answer")
147+
import sttp.tapir.generic.auto._
148+
val caught =
149+
intercept[OpenAIException](syncClient.createChatCompletion[MathReasoning](ChatBody(Nil, ChatCompletionModel.GPT4oMini)) { body =>
150+
Right(mockRes)
151+
})
152+
153+
// then
154+
val expectedError = new DeserializationOpenAIException("no choices found in response", null)
155+
caught.getClass shouldBe expectedError.getClass: Unit
156+
caught.message shouldBe expectedError.message: Unit
157+
caught.cause shouldBe null
158+
caught.code shouldBe expectedError.code: Unit
159+
caught.param shouldBe expectedError.param: Unit
160+
caught.`type` shouldBe expectedError.`type`
161+
}
82162
}

openai/src/test/scala/sttp/ai/openai/openai/fixtures/CompletionsFixture.scala

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,49 @@ object CompletionsFixture {
135135
| }
136136
| }
137137
|}""".stripMargin
138+
139+
/** Structured Outputs/JSON Schema support a response from Ollama with a structured content that can be deserialized to a typed value, in
140+
* this case a JSON object with steps and final answer.
141+
*/
142+
val structuredOutputsResponse =
143+
"""{
144+
| "id": "chatcmpl-689",
145+
| "object": "chat.completion",
146+
| "created": 1772595908,
147+
| "model": "qwen3:8b",
148+
| "system_fingerprint": "fp_ollama",
149+
| "choices": [
150+
| {
151+
| "index": 0,
152+
| "message": {
153+
| "role": "assistant",
154+
| "content": "{ \"steps\": [\n {\n \"explanation\": \"Start with the equation: 8x + 7 = -23.\",\n \"output\": \"8x + 7 = -23\"\n },\n {\n \"explanation\": \"Subtract 7 from both sides to isolate the term with x. This cancels out the +7 on the left and adjusts the right side accordingly.\",\n \"output\": \"8x = -23 - 7\"\n },\n {\n \"explanation\": \"Simplify the right side by performing the subtraction: -23 - 7 equals -30.\",\n \"output\": \"8x = -30\"\n },\n {\n \"explanation\": \"Divide both sides by 8 to solve for x. This undoes the multiplication by 8.\",\n \"output\": \"x = -30 / 8\"\n },\n {\n \"explanation\": \"Simplify the fraction by dividing both numerator and denominator by their greatest common divisor (which is 2).\",\n \"output\": \"x = -15/4\"\n },\n {\n \"explanation\": \"Verify the solution by substituting x = -15/4 back into the original equation to ensure both sides are equal.\",\n \"output\": \"8(-15/4) + 7 = -30 + 7 = -23 ✓\"\n }\n],\n\"finalAnswer\": \"x = -\\frac{15}{4}\" }",
155+
| "reasoning": "Okay, so I need to solve the equation 8x + 7 = -23. Let me think about how to approach this. Hmm, algebra, right? The goal is to find the value of x that makes this equation true. Let me recall the steps for solving linear equations. \n\nFirst, I remember that to solve for x, I need to isolate it on one side of the equation. That means I have to get rid of the numbers around it. The equation has both a multiplication and an addition, so I should probably reverse those operations. The order of operations is important here. Since the equation has 8x plus 7, I need to undo the addition first and then the multiplication. \n\nWait, let me make sure. In the equation 8x + 7 = -23, the operations happening to x are first multiplying by 8 and then adding 7. To reverse that, I should do the opposite operations in the reverse order. So, first subtract 7 from both sides to undo the addition, and then divide by 8 to undo the multiplication. Yeah, that sounds right. \n\nLet me write that down step by step. Starting with the original equation:\n\n8x + 7 = -23\n\nFirst step: Subtract 7 from both sides to get rid of the +7 on the left. So:\n\n8x + 7 - 7 = -23 - 7\n\nSimplifying both sides:\n\nOn the left side, 7 - 7 cancels out, leaving 8x. On the right side, -23 - 7 is... let me calculate that. -23 minus 7 is like going further into the negatives. So that's -30. So now the equation is:\n\n8x = -30\n\nNow, the next step is to get x by itself. Since 8 is multiplied by x, I need to divide both sides by 8. \n\nSo:\n\n8x / 8 = -30 / 8\n\nSimplifying:\n\nx = -30/8\n\nHmm, can this fraction be simplified? Let me check. Both numerator and denominator are divisible by 2. Dividing numerator and denominator by 2:\n\n-30 ÷ 2 = -15\n\n8 ÷ 2 = 4\n\nSo, x = -15/4\n\nWait, is that the simplest form? Let me confirm. 15 and 4 have no common factors besides 1, so yes, -15/4 is the simplified fraction. Alternatively, as a mixed number, it would be -3 3/4, but unless the question specifies, the improper fraction is probably acceptable. \n\nLet me check my steps again to make sure I didn't make a mistake. Starting with 8x + 7 = -23. Subtract 7 from both sides: -23 -7 is indeed -30. Then divide by 8: -30 divided by 8. Yes, that's -3.75, which is equivalent to -15/4. \n\nAlternatively, maybe I can check by plugging the value back into the original equation to verify. Let's do that. If x = -15/4, then 8x is 8 * (-15/4). Let me compute that. 8 divided by 4 is 2, so 2 * (-15) = -30. Then add 7: -30 + 7 = -23. Which matches the right side of the equation. Perfect, that checks out. \n\nSo, the solution seems correct. Therefore, x equals -15 over 4. \n\nWait, but maybe I should present it as a decimal? The question didn't specify, but fractions are usually preferred in algebra unless told otherwise. So, -15/4 is the exact answer, while -3.75 is the decimal equivalent. \n\nAlternatively, if I wanted to write it as a mixed number, it's -3 and 3/4. But again, unless specified, improper fraction is fine. \n\nSo, to recap, the steps were:\n\n1. Subtract 7 from both sides: 8x = -30\n2. Divide both sides by 8: x = -30/8\n3. Simplify the fraction: x = -15/4\n\nYep, that's solid. I think that's the correct solution. No mistakes noticed in the process. The check confirms it. So, the answer should be x equals negative fifteen fourths.\n"
156+
| },
157+
| "finish_reason": "stop"
158+
| }
159+
| ],
160+
| "usage": {
161+
| "prompt_tokens": 1003,
162+
| "completion_tokens": 317,
163+
| "total_tokens": 1320
164+
| }
165+
|}
166+
|""".stripMargin
167+
168+
val structuredOutputsResponseWithoutChoices =
169+
"""{
170+
| "id": "chatcmpl-689",
171+
| "object": "chat.completion",
172+
| "created": 1772595908,
173+
| "model": "qwen3:8b",
174+
| "system_fingerprint": "fp_ollama",
175+
| "choices": [],
176+
| "usage": {
177+
| "prompt_tokens": 1003,
178+
| "completion_tokens": 317,
179+
| "total_tokens": 1320
180+
| }
181+
|}
182+
|""".stripMargin
138183
}

0 commit comments

Comments
 (0)