Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions openai/src/main/scala/sttp/ai/openai/OpenAISyncClient.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}