Skip to content

Commit 566ef8d

Browse files
authored
Added Responses API support + small fix for optional parameters (#378)
1 parent 286d6e3 commit 566ef8d

24 files changed

+2809
-82
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -463,8 +463,9 @@ object Main extends App {
463463
val responseFormat: ResponseFormat.JsonSchema =
464464
ResponseFormat.JsonSchema(
465465
name = "mathReasoning",
466-
strict = true,
467-
schema = jsonSchema
466+
strict = Some(true),
467+
schema = Some(jsonSchema),
468+
description = None
468469
)
469470

470471
val bodyMessages: Seq[Message] = Seq(

core/src/main/scala/sttp/openai/OpenAI.scala

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import sttp.openai.requests.images.variations.ImageVariationsConfig
3535
import sttp.openai.requests.models.ModelsResponseData.{DeletedModelData, ModelData, ModelsResponse}
3636
import sttp.openai.requests.moderations.ModerationsRequestBody.ModerationsBody
3737
import sttp.openai.requests.moderations.ModerationsResponseData.ModerationData
38+
import sttp.openai.requests.responses._
3839
import sttp.openai.requests.threads.QueryParameters
3940
import sttp.openai.requests.threads.ThreadsRequestBody.CreateThreadBody
4041
import sttp.openai.requests.threads.ThreadsResponseData.{DeleteThreadResponse, ThreadData}
@@ -405,6 +406,105 @@ class OpenAI(authToken: String, baseUri: Uri = OpenAIUris.OpenAIBaseUri) {
405406
.delete(openAIUris.chatCompletion(completionId))
406407
.response(asJson_parseErrors[DeleteChatCompletionResponse])
407408

409+
/** Creates a model response.
410+
*
411+
* Provide text or image inputs to generate text or JSON outputs. Have the model call your own custom code or use built-in tools like web
412+
* search or file search to use your own data as input for the model's response.
413+
*
414+
* [[https://platform.openai.com/docs/api-reference/responses/create]]
415+
*
416+
* @param requestBody
417+
* Model response request body.
418+
*
419+
* @return
420+
* Returns a Response object.
421+
*/
422+
def createModelResponse(requestBody: ResponsesRequestBody): Request[Either[OpenAIException, ResponsesResponseBody]] =
423+
openAIAuthRequest
424+
.post(openAIUris.Responses)
425+
.body(asJson(requestBody))
426+
.response(asJson_parseErrors[ResponsesResponseBody])
427+
428+
/** Retrieves a model response with the given ID.
429+
*
430+
* [[https://platform.openai.com/docs/api-reference/responses/get]]
431+
*
432+
* @param responseId
433+
* The ID of the response to retrieve.
434+
*
435+
* @return
436+
* The Response object matching the specified ID.
437+
*/
438+
def getModelResponse(
439+
responseId: String,
440+
queryParameters: GetResponseQueryParameters
441+
): Request[Either[OpenAIException, ResponsesResponseBody]] = {
442+
val uri = openAIUris
443+
.response(responseId)
444+
.withParams(queryParameters.toMap)
445+
446+
openAIAuthRequest
447+
.get(uri)
448+
.response(asJson_parseErrors[ResponsesResponseBody])
449+
}
450+
451+
/** Deletes a model response with the given ID.
452+
*
453+
* [[https://platform.openai.com/docs/api-reference/responses/delete]]
454+
*
455+
* @param responseId
456+
* The ID of the chat completion to delete.
457+
*
458+
* @return
459+
* A deletion confirmation object.
460+
*/
461+
def deleteModelResponse(responseId: String): Request[Either[OpenAIException, DeleteModelResponseResponse]] =
462+
openAIAuthRequest
463+
.delete(openAIUris.response(responseId))
464+
.response(asJson_parseErrors[DeleteModelResponseResponse])
465+
466+
/** Cancels a model response with the given ID.
467+
*
468+
* Only responses created with the background parameter set to true can be cancelled
469+
*
470+
* [[https://platform.openai.com/docs/api-reference/responses/cancel]]
471+
*
472+
* @param responseId
473+
* The ID of the Upload.
474+
*
475+
* @return
476+
* The Upload object with status cancelled.
477+
*/
478+
def cancelResponse(responseId: String): Request[Either[OpenAIException, ResponsesResponseBody]] =
479+
openAIAuthRequest
480+
.post(openAIUris.cancelResponse(responseId))
481+
.response(asJson_parseErrors[ResponsesResponseBody])
482+
483+
/** Returns a list of input items for a given response.
484+
*
485+
* [[https://platform.openai.com/docs/api-reference/responses/input-items]]
486+
*
487+
* @param responseId
488+
* The ID of the response to retrieve input items for.
489+
* @param queryParameters
490+
* Query parameters for pagination and filtering.
491+
*
492+
* @return
493+
* A list of input items for the response.
494+
*/
495+
def listResponsesInputItems(
496+
responseId: String,
497+
queryParameters: ListInputItemsQueryParameters = ListInputItemsQueryParameters.empty
498+
): Request[Either[OpenAIException, InputItemsListResponseBody]] = {
499+
val uri = openAIUris
500+
.responseInputItems(responseId)
501+
.withParams(queryParameters.toMap)
502+
503+
openAIAuthRequest
504+
.get(uri)
505+
.response(asJson_parseErrors[InputItemsListResponseBody])
506+
}
507+
408508
/** Returns a list of files that belong to the user's organization.
409509
*
410510
* [[https://platform.openai.com/docs/api-reference/files]]
@@ -1533,6 +1633,7 @@ private class OpenAIUris(val baseUri: Uri) {
15331633
val Translations: Uri = audioBase.addPath("translations")
15341634
val Speech: Uri = audioBase.addPath("speech")
15351635
val VariationsImage: Uri = imageBase.addPath("variations")
1636+
val Responses: Uri = uri"$baseUri/responses"
15361637

15371638
val Assistants: Uri = uri"$baseUri/assistants"
15381639
val Threads: Uri = uri"$baseUri/threads"
@@ -1563,6 +1664,10 @@ private class OpenAIUris(val baseUri: Uri) {
15631664

15641665
def assistant(assistantId: String): Uri = Assistants.addPath(assistantId)
15651666

1667+
def response(responseId: String): Uri = Responses.addPath(responseId)
1668+
def cancelResponse(responseId: String): Uri = response(responseId).addPath("cancel")
1669+
def responseInputItems(responseId: String): Uri = response(responseId).addPath("input_items")
1670+
15661671
def thread(threadId: String): Uri = Threads.addPath(threadId)
15671672
def threadMessages(threadId: String): Uri = Threads.addPath(threadId).addPath("messages")
15681673
def threadMessage(threadId: String, messageId: String): Uri = Threads.addPath(threadId).addPath("messages").addPath(messageId)

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import sttp.openai.requests.images.variations.ImageVariationsConfig
3232
import sttp.openai.requests.models.ModelsResponseData.{DeletedModelData, ModelData, ModelsResponse}
3333
import sttp.openai.requests.moderations.ModerationsRequestBody.ModerationsBody
3434
import sttp.openai.requests.moderations.ModerationsResponseData.ModerationData
35+
import sttp.openai.requests.responses._
3536
import sttp.openai.requests.threads.QueryParameters
3637
import sttp.openai.requests.threads.ThreadsRequestBody.CreateThreadBody
3738
import sttp.openai.requests.threads.ThreadsResponseData.{DeleteThreadResponse, ThreadData}
@@ -267,6 +268,81 @@ class OpenAISyncClient private (
267268
def deleteChatCompletion(completionId: String): DeleteChatCompletionResponse =
268269
sendOrThrow(openAI.deleteChatCompletion(completionId))
269270

271+
/** Creates a model response.
272+
*
273+
* Provide text or image inputs to generate text or JSON outputs. Have the model call your own custom code or use built-in tools like web
274+
* search or file search to use your own data as input for the model's response.
275+
*
276+
* [[https://platform.openai.com/docs/api-reference/responses/create]]
277+
*
278+
* @param requestBody
279+
* Model response request body.
280+
*/
281+
def createModelResponse(requestBody: ResponsesRequestBody): ResponsesResponseBody =
282+
sendOrThrow(openAI.createModelResponse(requestBody))
283+
284+
/** Retrieves a model response with the given ID.
285+
*
286+
* [[https://platform.openai.com/docs/api-reference/responses/get]]
287+
*
288+
* @param responseId
289+
* The ID of the response to retrieve.
290+
*
291+
* @return
292+
* The Response object matching the specified ID.
293+
*/
294+
def getModelResponse(
295+
responseId: String,
296+
queryParameters: GetResponseQueryParameters
297+
): ResponsesResponseBody =
298+
sendOrThrow(openAI.getModelResponse(responseId, queryParameters))
299+
300+
/** Deletes a model response with the given ID.
301+
*
302+
* [[https://platform.openai.com/docs/api-reference/responses/delete]]
303+
*
304+
* @param responseId
305+
* The ID of the chat completion to delete.
306+
*
307+
* @return
308+
* A deletion confirmation object.
309+
*/
310+
def deleteModelResponse(responseId: String): DeleteModelResponseResponse =
311+
sendOrThrow(openAI.deleteModelResponse(responseId))
312+
313+
/** Cancels a model response with the given ID.
314+
*
315+
* Only responses created with the background parameter set to true can be cancelled
316+
*
317+
* [[https://platform.openai.com/docs/api-reference/responses/cancel]]
318+
*
319+
* @param responseId
320+
* The ID of the Upload.
321+
*
322+
* @return
323+
* The Upload object with status cancelled.
324+
*/
325+
def cancelResponse(responseId: String): ResponsesResponseBody =
326+
sendOrThrow(openAI.cancelResponse(responseId))
327+
328+
/** Returns a list of input items for a given response.
329+
*
330+
* [[https://platform.openai.com/docs/api-reference/responses/list-input-items]]
331+
*
332+
* @param responseId
333+
* The ID of the response to retrieve input items for.
334+
* @param queryParameters
335+
* Query parameters for pagination and filtering.
336+
*
337+
* @return
338+
* A list of input items for the response.
339+
*/
340+
def listResponsesInputItems(
341+
responseId: String,
342+
queryParameters: ListInputItemsQueryParameters = ListInputItemsQueryParameters.empty
343+
): InputItemsListResponseBody =
344+
sendOrThrow(openAI.listResponsesInputItems(responseId, queryParameters))
345+
270346
/** Returns a list of files that belong to the user's organization.
271347
*
272348
* [[https://platform.openai.com/docs/api-reference/files]]

core/src/main/scala/sttp/openai/json/SnakePickle.scala

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package sttp.openai.json
22

3+
import ujson._
4+
35
/** An object that transforms all snake_case keys into camelCase [[https://com-lihaoyi.github.io/upickle/#CustomConfiguration]] */
46
object SnakePickle extends upickle.AttributeTagged {
7+
8+
override def tagName: String = "type"
9+
510
private def camelToSnake(s: String): String =
611
s.replaceAll("([A-Z])", "#$1").split('#').map(_.toLowerCase).mkString("_")
712

@@ -16,12 +21,6 @@ object SnakePickle extends upickle.AttributeTagged {
1621
override def objectAttributeKeyWriteMap(s: CharSequence): String =
1722
camelToSnake(s.toString)
1823

19-
override def objectTypeKeyReadMap(s: CharSequence): String =
20-
snakeToCamel(s.toString)
21-
22-
override def objectTypeKeyWriteMap(s: CharSequence): String =
23-
camelToSnake(s.toString)
24-
2524
/** This is required in order to parse null values into Scala's Option */
2625
override implicit def OptionWriter[T: SnakePickle.Writer]: Writer[Option[T]] =
2726
implicitly[SnakePickle.Writer[T]].comap[Option[T]] {
@@ -34,3 +33,45 @@ object SnakePickle extends upickle.AttributeTagged {
3433
override def visitNull(index: Int) = None
3534
}
3635
}
36+
37+
object SerializationHelpers {
38+
39+
/** Creates a ReadWriter for nested discriminator patterns where the object is wrapped in another object with a discriminator field
40+
* pointing to the nested content. Additionally, automatically added discriminator field is removed from the nested object.
41+
*
42+
* For example: {"type": "json_schema", "json_schema": {...actual object...}}
43+
*
44+
* @param discriminatorValue
45+
* The value for the discriminator field (e.g., "json_schema")
46+
* @param nestedField
47+
* The name of the field containing the nested object (e.g., "json_schema")
48+
* @param baseRW
49+
* The base ReadWriter for the type T (typically SnakePickle.macroRW)
50+
* @return
51+
* A ReadWriter that wraps the object in the nested discriminator structure
52+
*/
53+
def withNestedDiscriminator[T](discriminatorValue: String, nestedField: String)(implicit
54+
baseRW: SnakePickle.Writer[T]
55+
): SnakePickle.Writer[T] =
56+
SnakePickle
57+
.writer[Value]
58+
.comap { t =>
59+
val baseJson = SnakePickle.writeJs(t)
60+
// Filter out tagName from nested case class
61+
val cleanedJson = baseJson match {
62+
case obj: Obj =>
63+
val filtered = obj.obj.filterNot { case (key, _) =>
64+
key == SnakePickle.tagName
65+
}
66+
Obj.from(filtered)
67+
case other => other
68+
}
69+
Obj(
70+
SnakePickle.tagName -> discriminatorValue,
71+
nestedField -> cleanedJson
72+
)
73+
}
74+
75+
def caseObjectWithDiscriminatorWriter[T](discriminatorValue: String): SnakePickle.Writer[T] =
76+
SnakePickle.writer[Value].comap(_ => Obj(SnakePickle.tagName -> Str(discriminatorValue)))
77+
}

0 commit comments

Comments
 (0)