diff --git a/openai/src/main/scala/sttp/ai/openai/OpenAISyncClient.scala b/openai/src/main/scala/sttp/ai/openai/OpenAISyncClient.scala index 7156f629..f4fec4b2 100644 --- a/openai/src/main/scala/sttp/ai/openai/OpenAISyncClient.scala +++ b/openai/src/main/scala/sttp/ai/openai/OpenAISyncClient.scala @@ -3,6 +3,7 @@ package sttp.ai.openai import sttp.client4.{DefaultSyncBackend, Request, SyncBackend} import sttp.model.Uri import sttp.ai.openai.OpenAIExceptions.OpenAIException +import sttp.ai.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException import sttp.ai.openai.config.OpenAIConfig import sttp.ai.openai.requests.admin.{QueryParameters => _, _} import sttp.ai.openai.requests.assistants.AssistantsRequestBody.{CreateAssistantBody, ModifyAssistantBody} @@ -14,6 +15,7 @@ import sttp.ai.openai.requests.batch.{BatchRequestBody, BatchResponse, ListBatch import sttp.ai.openai.requests.completions.CompletionsRequestBody.CompletionsBody import sttp.ai.openai.requests.completions.CompletionsResponseData.CompletionsResponse import sttp.ai.openai.requests.completions.chat +import sttp.ai.openai.requests.completions.chat.ChatRequestBody.ResponseFormat.JsonSchema import sttp.ai.openai.requests.completions.chat.ChatRequestBody.{ChatBody, UpdateChatCompletionRequestBody} import sttp.ai.openai.requests.completions.chat.ChatRequestResponseData.{ ChatResponse, @@ -51,6 +53,7 @@ import sttp.ai.openai.requests.vectorstore.file.VectorStoreFileResponseData.{ VectorStoreFile } import sttp.ai.openai.requests.{admin, batch, finetuning} +import sttp.tapir.{Schema => TapirSchema} import java.io.File @@ -199,6 +202,39 @@ class OpenAISyncClient private ( def createChatCompletion(chatBody: ChatBody): ChatResponse = sendOrThrow(openAI.createChatCompletion(chatBody)) + /** Creates a typed model response for the given chat conversation and response format defined in chatBody. + * + * @param chatBody + * Chat request body. + * @param responseName + * An optional name for the response. + * @param parseFunction + * Function that parse the response message to a given object or an error message in case of failure. Use Either to treat failure as + * checked error instead of unchecked Exception. + * @tparam T + * The return type, which must have a TapirSchema instance available. + */ + def createChatCompletion[T: TapirSchema](chatBody: ChatBody, responseName: Option[String] = None)( + parseFunction: String => Either[String, T] + ): T = { + val withResponseFormat = + if (chatBody.responseFormat.nonEmpty) chatBody + else chatBody.copy(responseFormat = Some(JsonSchema.withTapirSchema[T](responseName.getOrElse("response"), None, Some(true)))) + + val finalRes: Either[OpenAIException, T] = for { + res <- customizeRequest.apply(openAI.createChatCompletion(withResponseFormat)).send(backend).body + message <- res.choices.headOption + .map(_.message) + .toRight(new DeserializationOpenAIException("no choices found in response", null)) + content <- parseFunction(message.content).left.map(err => new DeserializationOpenAIException(err, null)) + } yield content + + finalRes match { + case Right(value) => value + case Left(exception) => throw exception + } + } + /** Get a stored chat completion. Only chat completions that have been created with the store parameter set to true will be returned. * * [[https://platform.openai.com/docs/api-reference/chat/get]] diff --git a/openai/src/test/scala/sttp/ai/openai/openai/client/SyncClientSpec.scala b/openai/src/test/scala/sttp/ai/openai/openai/client/SyncClientSpec.scala index 911a0bb3..54ce0b3c 100644 --- a/openai/src/test/scala/sttp/ai/openai/openai/client/SyncClientSpec.scala +++ b/openai/src/test/scala/sttp/ai/openai/openai/client/SyncClientSpec.scala @@ -8,9 +8,13 @@ import sttp.client4.testing.ResponseStub import sttp.model.StatusCode import sttp.model.StatusCode._ import sttp.ai.openai.OpenAIExceptions.OpenAIException +import sttp.ai.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException import sttp.ai.openai.fixtures.ErrorFixture +import sttp.ai.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel} import sttp.ai.openai.requests.models.ModelsResponseData._ +import sttp.ai.openai.requests.responses.ResponsesModel.GPT4oMini import sttp.ai.openai.{CustomizeOpenAIRequest, OpenAISyncClient} +import sttp.tapir.Schema import java.util.concurrent.atomic.AtomicReference @@ -79,4 +83,80 @@ class SyncClientSpec extends AnyFlatSpec with Matchers with EitherValues { capturedRequest.get().headers.find(_.is("X-Test")).map(_.value) shouldBe Some("test"): Unit capturedRequest.get().headers.find(_.is("X-Test-2")).map(_.value) shouldBe Some("test-2") } + + case class Step(explanation: String, output: String) + + case class MathReasoning(steps: List[Step], finalAnswer: String) + + "typed createChatCompletion" should "be ok" in { + // given + val capturedRequest = new AtomicReference[GenericRequest[_, _]](null) + val syncBackendStub = DefaultSyncBackend.stub.whenAnyRequest.thenRespondF { request => + capturedRequest.set(request) + ResponseStub.adjust(sttp.ai.openai.fixtures.CompletionsFixture.structuredOutputsResponse, StatusCode.Ok) + } + val syncClient = OpenAISyncClient(authToken = "test-token", backend = syncBackendStub) + + // when + val mockRes = MathReasoning(Nil, "final answer") + import sttp.tapir.generic.auto._ + val res = syncClient.createChatCompletion[MathReasoning](ChatBody(Nil, ChatCompletionModel.GPT4oMini)) { body => + Right(mockRes) + } + + // then + res shouldBe mockRes + } + + "typed createChatCompletion" should "throw exception with parsed error" in { + // given + val capturedRequest = new AtomicReference[GenericRequest[_, _]](null) + val syncBackendStub = DefaultSyncBackend.stub.whenAnyRequest.thenRespondF { request => + capturedRequest.set(request) + ResponseStub.adjust(sttp.ai.openai.fixtures.CompletionsFixture.structuredOutputsResponse, StatusCode.Ok) + } + val syncClient = OpenAISyncClient(authToken = "test-token", backend = syncBackendStub) + + // when + import sttp.tapir.generic.auto._ + val caught = + intercept[OpenAIException](syncClient.createChatCompletion[MathReasoning](ChatBody(Nil, ChatCompletionModel.GPT4oMini)) { body => + Left("parsed error") + }) + + val expectedError = new DeserializationOpenAIException("parsed error", null) + caught.getClass shouldBe expectedError.getClass: Unit + caught.message shouldBe expectedError.message: Unit + caught.cause shouldBe null + caught.code shouldBe expectedError.code: Unit + caught.param shouldBe expectedError.param: Unit + caught.`type` shouldBe expectedError.`type` + } + + "typed createChatCompletion" should "throw exception without choices" in { + // given + val capturedRequest = new AtomicReference[GenericRequest[_, _]](null) + val syncBackendStub = DefaultSyncBackend.stub.whenAnyRequest.thenRespondF { request => + capturedRequest.set(request) + ResponseStub.adjust(sttp.ai.openai.fixtures.CompletionsFixture.structuredOutputsResponseWithoutChoices, StatusCode.Ok) + } + val syncClient = OpenAISyncClient(authToken = "test-token", backend = syncBackendStub) + + // when + val mockRes = MathReasoning(Nil, "final answer") + import sttp.tapir.generic.auto._ + val caught = + intercept[OpenAIException](syncClient.createChatCompletion[MathReasoning](ChatBody(Nil, ChatCompletionModel.GPT4oMini)) { body => + Right(mockRes) + }) + + // then + val expectedError = new DeserializationOpenAIException("no choices found in response", null) + caught.getClass shouldBe expectedError.getClass: Unit + caught.message shouldBe expectedError.message: Unit + caught.cause shouldBe null + caught.code shouldBe expectedError.code: Unit + caught.param shouldBe expectedError.param: Unit + caught.`type` shouldBe expectedError.`type` + } } diff --git a/openai/src/test/scala/sttp/ai/openai/openai/fixtures/CompletionsFixture.scala b/openai/src/test/scala/sttp/ai/openai/openai/fixtures/CompletionsFixture.scala index 46e7d099..cbae9e97 100644 --- a/openai/src/test/scala/sttp/ai/openai/openai/fixtures/CompletionsFixture.scala +++ b/openai/src/test/scala/sttp/ai/openai/openai/fixtures/CompletionsFixture.scala @@ -135,4 +135,49 @@ object CompletionsFixture { | } | } |}""".stripMargin + + /** Structured Outputs/JSON Schema support a response from Ollama with a structured content that can be deserialized to a typed value, in + * this case a JSON object with steps and final answer. + */ + val structuredOutputsResponse = + """{ + | "id": "chatcmpl-689", + | "object": "chat.completion", + | "created": 1772595908, + | "model": "qwen3:8b", + | "system_fingerprint": "fp_ollama", + | "choices": [ + | { + | "index": 0, + | "message": { + | "role": "assistant", + | "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}\" }", + | "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" + | }, + | "finish_reason": "stop" + | } + | ], + | "usage": { + | "prompt_tokens": 1003, + | "completion_tokens": 317, + | "total_tokens": 1320 + | } + |} + |""".stripMargin + + val structuredOutputsResponseWithoutChoices = + """{ + | "id": "chatcmpl-689", + | "object": "chat.completion", + | "created": 1772595908, + | "model": "qwen3:8b", + | "system_fingerprint": "fp_ollama", + | "choices": [], + | "usage": { + | "prompt_tokens": 1003, + | "completion_tokens": 317, + | "total_tokens": 1320 + | } + |} + |""".stripMargin }