Skip to content

Commit c0a3945

Browse files
ahmedmirza994ahmedharis994Ahmed Haris Javaid Mirza
authored
feat(assistants): add structured response (#391)
Co-authored-by: ahmedharis994 <haris.mirza@10Pearls.com> Co-authored-by: Ahmed Haris Javaid Mirza <ahmedharisjavaidmirza@Ahmed-Haris-MacBook-Pro.local>
1 parent cf06188 commit c0a3945

File tree

5 files changed

+214
-33
lines changed

5 files changed

+214
-33
lines changed

openai-client/src/commonTest/kotlin/com/aallam/openai/client/TestAssistants.kt

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@ import com.aallam.openai.api.assistant.AssistantResponseFormat
44
import com.aallam.openai.api.assistant.AssistantTool
55
import com.aallam.openai.api.assistant.assistantRequest
66
import com.aallam.openai.api.chat.ToolCall
7-
import com.aallam.openai.api.core.RequestOptions
87
import com.aallam.openai.api.model.ModelId
98
import com.aallam.openai.api.run.RequiredAction
109
import com.aallam.openai.api.run.Run
1110
import com.aallam.openai.client.internal.JsonLenient
12-
import kotlin.test.*
11+
import kotlinx.serialization.json.JsonArray
12+
import kotlinx.serialization.json.JsonPrimitive
13+
import kotlinx.serialization.json.buildJsonObject
14+
import kotlinx.serialization.json.put
15+
import kotlin.test.Test
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertIs
18+
import kotlin.test.assertNull
19+
import kotlin.test.assertTrue
1320

1421
class TestAssistants : TestOpenAI() {
1522

@@ -144,4 +151,64 @@ class TestAssistants : TestOpenAI() {
144151
val action = decoded.requiredAction as RequiredAction.SubmitToolOutputs
145152
assertIs<ToolCall.Function>(action.toolOutputs.toolCalls.first())
146153
}
154+
155+
@Test
156+
fun jsonSchemaAssistant() = test {
157+
val jsonSchema = AssistantResponseFormat.JSON_SCHEMA(
158+
name = "TestSchema",
159+
description = "A test schema",
160+
schema = buildJsonObject {
161+
put("type", "object")
162+
put("properties", buildJsonObject {
163+
put("name", buildJsonObject {
164+
put("type", "string")
165+
})
166+
})
167+
put("required", JsonArray(listOf(JsonPrimitive("name"))))
168+
put("additionalProperties", false)
169+
},
170+
strict = true
171+
)
172+
173+
val request = assistantRequest {
174+
name = "Schema Assistant"
175+
model = ModelId("gpt-4o-mini")
176+
responseFormat = jsonSchema
177+
}
178+
179+
val assistant = openAI.assistant(
180+
request = request,
181+
)
182+
assertEquals(request.name, assistant.name)
183+
assertEquals(request.model, assistant.model)
184+
assertEquals(request.responseFormat, assistant.responseFormat)
185+
186+
val getAssistant = openAI.assistant(
187+
assistant.id,
188+
)
189+
assertEquals(getAssistant, assistant)
190+
191+
val assistants = openAI.assistants()
192+
assertTrue { assistants.isNotEmpty() }
193+
194+
val updated = assistantRequest {
195+
name = "Updated Schema Assistant"
196+
responseFormat = AssistantResponseFormat.AUTO
197+
}
198+
val updatedAssistant = openAI.assistant(
199+
assistant.id,
200+
updated,
201+
)
202+
assertEquals(updated.name, updatedAssistant.name)
203+
assertEquals(updated.responseFormat, updatedAssistant.responseFormat)
204+
205+
openAI.delete(
206+
updatedAssistant.id,
207+
)
208+
209+
val fileGetAfterDelete = openAI.assistant(
210+
updatedAssistant.id,
211+
)
212+
assertNull(fileGetAfterDelete)
213+
}
147214
}

openai-core/src/commonMain/kotlin/com.aallam.openai.api/assistant/AssistantRequest.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,23 @@ public data class AssistantRequest(
6767
* Specifies the format that the model must output. Compatible with GPT-4o, GPT-4 Turbo, and all GPT-3.5 Turbo
6868
* models since gpt-3.5-turbo-1106.
6969
*
70-
* Setting to [AssistantResponseFormat.JsonObject] enables JSON mode, which guarantees the message the model
70+
* Setting to [AssistantResponseFormat.JSON_SCHEMA] enables Structured Outputs which ensures the model will match your supplied JSON schema.
71+
*
72+
* Structured Outputs ([AssistantResponseFormat.JSON_SCHEMA]) are available in our latest large language models, starting with GPT-4o:
73+
* 1. gpt-4o-mini-2024-07-18 and later
74+
* 2. gpt-4o-2024-08-06 and later
75+
*
76+
* Older models like gpt-4-turbo and earlier may use JSON mode ([AssistantResponseFormat.JSON_OBJECT]) instead.
77+
*
78+
* Setting to [AssistantResponseFormat.JSON_OBJECT] enables JSON mode, which guarantees the message the model
7179
* generates is valid JSON.
7280
*
7381
* important: when using JSON mode, you must also instruct the model to produce JSON yourself via a system or user
7482
* message. Without this, the model may generate an unending stream of whitespace until the generation reaches the
7583
* token limit, resulting in a long-running and seemingly "stuck" request. Also note that the message content may be
7684
* partially cut off if finish_reason="length", which indicates the generation exceeded max_tokens or
7785
* the conversation exceeded the max context length.
86+
*
7887
*/
7988
@SerialName("response_format") val responseFormat: AssistantResponseFormat? = null,
8089
)

openai-core/src/commonMain/kotlin/com.aallam.openai.api/assistant/AssistantResponseFormat.kt

Lines changed: 99 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,51 +9,73 @@ import kotlinx.serialization.descriptors.buildClassSerialDescriptor
99
import kotlinx.serialization.descriptors.element
1010
import kotlinx.serialization.encoding.Decoder
1111
import kotlinx.serialization.encoding.Encoder
12-
import kotlinx.serialization.json.JsonElement
1312
import kotlinx.serialization.json.JsonObject
13+
import kotlinx.serialization.json.JsonObjectBuilder
1414
import kotlinx.serialization.json.JsonPrimitive
15+
import kotlinx.serialization.json.booleanOrNull
16+
import kotlinx.serialization.json.contentOrNull
17+
import kotlinx.serialization.json.jsonObject
1518
import kotlinx.serialization.json.jsonPrimitive
1619

1720
/**
18-
* string: auto is the default value
21+
* Represents the format of the response from the assistant.
1922
*
20-
* object: An object describing the expected output of the model. If json_object only function type tools are allowed to be passed to the Run.
21-
* If text, the model can return text or any value needed.
22-
* type: string Must be one of text or json_object.
23+
* @property type The type of the response format.
24+
* @property jsonSchema The JSON schema associated with the response format, if type is "json_schema" otherwise null.
2325
*/
2426
@BetaOpenAI
2527
@Serializable(with = AssistantResponseFormat.ResponseFormatSerializer::class)
2628
public data class AssistantResponseFormat(
27-
val format: String? = null,
28-
val objectType: AssistantResponseType? = null,
29+
val type: String,
30+
val jsonSchema: JsonSchema? = null
2931
) {
32+
33+
/**
34+
* Represents a JSON schema.
35+
*
36+
* @property name The name of the schema.
37+
* @property description The description of the schema.
38+
* @property schema The actual JSON schema.
39+
* @property strict Indicates if the schema is strict.
40+
*/
3041
@Serializable
31-
public data class AssistantResponseType(
32-
val type: String
42+
public data class JsonSchema(
43+
val name: String,
44+
val description: String? = null,
45+
val schema: JsonObject,
46+
val strict: Boolean? = null
3347
)
3448

3549
public companion object {
36-
public val AUTO: AssistantResponseFormat = AssistantResponseFormat(format = "auto")
37-
public val TEXT: AssistantResponseFormat = AssistantResponseFormat(objectType = AssistantResponseType(type = "text"))
38-
public val JSON_OBJECT: AssistantResponseFormat = AssistantResponseFormat(objectType = AssistantResponseType(type = "json_object"))
50+
public val AUTO: AssistantResponseFormat = AssistantResponseFormat("auto")
51+
public val TEXT: AssistantResponseFormat = AssistantResponseFormat("text")
52+
public val JSON_OBJECT: AssistantResponseFormat = AssistantResponseFormat("json_object")
53+
54+
/**
55+
* Creates an instance of `AssistantResponseFormat` with type `json_schema`.
56+
*
57+
* @param name The name of the schema.
58+
* @param description The description of the schema.
59+
* @param schema The actual JSON schema.
60+
* @param strict Indicates if the schema is strict.
61+
* @return An instance of `AssistantResponseFormat` with the specified JSON schema.
62+
*/
63+
public fun JSON_SCHEMA(
64+
name: String,
65+
description: String? = null,
66+
schema: JsonObject,
67+
strict: Boolean? = null
68+
): AssistantResponseFormat = AssistantResponseFormat(
69+
"json_schema",
70+
JsonSchema(name, description, schema, strict)
71+
)
3972
}
4073

74+
4175
public object ResponseFormatSerializer : KSerializer<AssistantResponseFormat> {
4276
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("AssistantResponseFormat") {
43-
element<String>("format", isOptional = true)
44-
element<AssistantResponseType>("type", isOptional = true)
45-
}
46-
47-
override fun serialize(encoder: Encoder, value: AssistantResponseFormat) {
48-
val jsonEncoder = encoder as? kotlinx.serialization.json.JsonEncoder
49-
?: throw SerializationException("This class can be saved only by Json")
50-
51-
if (value.format != null) {
52-
jsonEncoder.encodeJsonElement(JsonPrimitive(value.format))
53-
} else if (value.objectType != null) {
54-
val jsonElement: JsonElement = JsonObject(mapOf("type" to JsonPrimitive(value.objectType.type)))
55-
jsonEncoder.encodeJsonElement(jsonElement)
56-
}
77+
element<String>("type")
78+
element<JsonSchema>("json_schema", isOptional = true) // Only for "json_schema" type
5779
}
5880

5981
override fun deserialize(decoder: Decoder): AssistantResponseFormat {
@@ -63,14 +85,63 @@ public data class AssistantResponseFormat(
6385
val jsonElement = jsonDecoder.decodeJsonElement()
6486
return when {
6587
jsonElement is JsonPrimitive && jsonElement.isString -> {
66-
AssistantResponseFormat(format = jsonElement.content)
88+
AssistantResponseFormat(type = jsonElement.content)
6789
}
6890
jsonElement is JsonObject && "type" in jsonElement -> {
6991
val type = jsonElement["type"]!!.jsonPrimitive.content
70-
AssistantResponseFormat(objectType = AssistantResponseType(type))
92+
when (type) {
93+
"json_schema" -> {
94+
val schemaObject = jsonElement["json_schema"]?.jsonObject
95+
val name = schemaObject?.get("name")?.jsonPrimitive?.content ?: ""
96+
val description = schemaObject?.get("description")?.jsonPrimitive?.contentOrNull
97+
val schema = schemaObject?.get("schema")?.jsonObject ?: JsonObject(emptyMap())
98+
val strict = schemaObject?.get("strict")?.jsonPrimitive?.booleanOrNull
99+
AssistantResponseFormat(
100+
type = "json_schema",
101+
jsonSchema = JsonSchema(name = name, description = description, schema = schema, strict = strict)
102+
)
103+
}
104+
"json_object" -> AssistantResponseFormat(type = "json_object")
105+
"auto" -> AssistantResponseFormat(type = "auto")
106+
"text" -> AssistantResponseFormat(type = "text")
107+
else -> throw SerializationException("Unknown response format type: $type")
108+
}
71109
}
72110
else -> throw SerializationException("Unknown response format: $jsonElement")
73111
}
74112
}
113+
114+
override fun serialize(encoder: Encoder, value: AssistantResponseFormat) {
115+
val jsonEncoder = encoder as? kotlinx.serialization.json.JsonEncoder
116+
?: throw SerializationException("This class can be saved only by Json")
117+
118+
val jsonElement = when (value.type) {
119+
"json_schema" -> {
120+
JsonObject(
121+
mapOf(
122+
"type" to JsonPrimitive("json_schema"),
123+
"json_schema" to JsonObject(
124+
mapOf(
125+
"name" to JsonPrimitive(value.jsonSchema?.name ?: ""),
126+
"description" to JsonPrimitive(value.jsonSchema?.description ?: ""),
127+
"schema" to (value.jsonSchema?.schema ?: JsonObject(emptyMap())),
128+
"strict" to JsonPrimitive(value.jsonSchema?.strict ?: false)
129+
)
130+
)
131+
)
132+
)
133+
}
134+
"json_object" -> JsonObject(mapOf("type" to JsonPrimitive("json_object")))
135+
"auto" -> JsonPrimitive("auto")
136+
"text" -> JsonObject(mapOf("type" to JsonPrimitive("text")))
137+
else -> throw SerializationException("Unsupported response format type: ${value.type}")
138+
}
139+
jsonEncoder.encodeJsonElement(jsonElement)
140+
}
141+
75142
}
76143
}
144+
145+
public fun JsonObject.Companion.buildJsonObject(block: JsonObjectBuilder.() -> Unit): JsonObject {
146+
return kotlinx.serialization.json.buildJsonObject(block)
147+
}

openai-core/src/commonMain/kotlin/com.aallam.openai.api/run/Run.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ public data class Run(
111111
@SerialName("usage") public val usage: Usage? = null,
112112

113113
/**
114-
* The Unix timestamp (in seconds) for when the run was completed.
114+
* The sampling temperature used for this run. If not set, defaults to 1.
115115
*/
116116
@SerialName("temperature") val temperature: Double? = null,
117117

sample/jvm/src/main/kotlin/com/aallam/openai/sample/jvm/AssistantsFunction.kt

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.aallam.openai.sample.jvm
22

33
import com.aallam.openai.api.BetaOpenAI
44
import com.aallam.openai.api.assistant.AssistantRequest
5+
import com.aallam.openai.api.assistant.AssistantResponseFormat
56
import com.aallam.openai.api.assistant.AssistantTool
67
import com.aallam.openai.api.assistant.Function
78
import com.aallam.openai.api.chat.ToolCall
@@ -17,7 +18,10 @@ import com.aallam.openai.api.run.RunRequest
1718
import com.aallam.openai.api.run.ToolOutput
1819
import com.aallam.openai.client.OpenAI
1920
import kotlinx.coroutines.delay
21+
import kotlinx.serialization.json.JsonArray
22+
import kotlinx.serialization.json.JsonPrimitive
2023
import kotlinx.serialization.json.add
24+
import kotlinx.serialization.json.buildJsonObject
2125
import kotlinx.serialization.json.put
2226
import kotlinx.serialization.json.putJsonArray
2327
import kotlinx.serialization.json.putJsonObject
@@ -29,6 +33,36 @@ suspend fun assistantsFunctions(openAI: OpenAI) {
2933
request = AssistantRequest(
3034
name = "Math Tutor",
3135
instructions = "You are a weather bot. Use the provided functions to answer questions.",
36+
responseFormat = AssistantResponseFormat.JSON_SCHEMA(
37+
name = "math_response",
38+
strict = true,
39+
schema = buildJsonObject {
40+
put("type", "object")
41+
putJsonObject("properties") {
42+
putJsonObject("steps") {
43+
put("type", "array")
44+
putJsonObject("items") {
45+
put("type", "object")
46+
putJsonObject("properties") {
47+
putJsonObject("explanation") {
48+
put("type", "string")
49+
}
50+
putJsonObject("output") {
51+
put("type", "string")
52+
}
53+
}
54+
put("required", JsonArray(listOf(JsonPrimitive("explanation"), JsonPrimitive("output"))))
55+
put("additionalProperties", false)
56+
}
57+
}
58+
putJsonObject("final_answer") {
59+
put("type", "string")
60+
}
61+
}
62+
put("additionalProperties", false)
63+
put("required", JsonArray(listOf(JsonPrimitive("steps"), JsonPrimitive("final_answer"))))
64+
},
65+
),
3266
tools = listOf(
3367
AssistantTool.FunctionTool(
3468
function = Function(
@@ -74,7 +108,7 @@ suspend fun assistantsFunctions(openAI: OpenAI) {
74108
)
75109
)
76110
),
77-
model = ModelId("gpt-4-1106-preview")
111+
model = ModelId("gpt-4o-mini")
78112
)
79113
)
80114

0 commit comments

Comments
 (0)