Skip to content

Commit 8173168

Browse files
committed
tool-calls: structured function calls without docs.
1 parent f68c491 commit 8173168

16 files changed

+841
-106
lines changed

openai-java-core/src/main/kotlin/com/openai/core/JsonSchemaValidator.kt

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -392,36 +392,45 @@ internal class JsonSchemaValidator private constructor() {
392392
// The schema must declare that additional properties are not allowed. For this check, it
393393
// does not matter if there are no "properties" in the schema.
394394
verify(
395-
schema.get(ADDITIONAL_PROPS) != null &&
396-
schema.get(ADDITIONAL_PROPS).asBoolean() == false,
395+
schema.get(ADDITIONAL_PROPS) != null && !schema.get(ADDITIONAL_PROPS).asBoolean(),
397396
path,
398397
) {
399398
"'$ADDITIONAL_PROPS' field is missing or is not set to 'false'."
400399
}
401400

402401
val properties = schema.get(PROPS)
403402

404-
// The "properties" field may be missing (there may be no properties to declare), but if it
405-
// is present, it must be a non-empty object, or validation cannot continue.
406-
// TODO: Decide if a missing or empty "properties" field is OK or not.
403+
// An object schema _must_ have a `"properties"` field, and it must contain at least one
404+
// property. The AI model will report an error relating to a missing or empty `"required"`
405+
// array if the "properties" field is missing or empty (and therefore the `"required"` array
406+
// will also be missing or empty). This condition can arise if a `Map` is used as the field
407+
// type: it will cause the generation of an object schema with no defined properties. If not
408+
// present or empty, validation cannot continue.
407409
verify(
408-
properties == null || (properties.isObject && !properties.isEmpty),
410+
properties != null && properties.isObject && !properties.isEmpty,
409411
path,
410-
{ "'$PROPS' field is not a non-empty object." },
412+
{ "'$PROPS' field is missing, empty or not an object." },
411413
) {
412414
return
413415
}
414416

415-
if (properties != null) { // Must be an object.
416-
// If a "properties" field is present, there must also be a "required" field. All
417-
// properties must be named in the list of required properties.
418-
validatePropertiesRequired(
419-
properties.fieldNames().asSequence().toSet(),
420-
schema.get(REQUIRED),
421-
"$path/$REQUIRED",
422-
)
423-
validateProperties(properties, "$path/$PROPS", depth)
417+
// Similarly, insist that the `"required"` array is present or stop validation.
418+
val required = schema.get(REQUIRED)
419+
420+
verify(
421+
required != null && required.isArray && !required.isEmpty,
422+
path,
423+
{ "'$REQUIRED' field is missing, empty or not an array." },
424+
) {
425+
return
424426
}
427+
428+
validatePropertiesRequired(
429+
properties.fieldNames().asSequence().toSet(),
430+
required,
431+
"$path/$REQUIRED",
432+
)
433+
validateProperties(properties, "$path/$PROPS", depth)
425434
}
426435

427436
/**
@@ -554,10 +563,10 @@ internal class JsonSchemaValidator private constructor() {
554563
*/
555564
private fun validatePropertiesRequired(
556565
propertyNames: Collection<String>,
557-
required: JsonNode?,
566+
required: JsonNode,
558567
path: String,
559568
) {
560-
val requiredNames = required?.map { it.asText() }?.toSet() ?: emptySet()
569+
val requiredNames = required.map { it.asText() }.toSet()
561570

562571
propertyNames.forEach { propertyName ->
563572
verify(propertyName in requiredNames, path) {

openai-java-core/src/main/kotlin/com/openai/core/StructuredOutputs.kt

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.openai.core
22

3+
import com.fasterxml.jackson.annotation.JsonTypeName
34
import com.fasterxml.jackson.databind.JsonNode
45
import com.fasterxml.jackson.databind.json.JsonMapper
6+
import com.fasterxml.jackson.databind.node.ObjectNode
57
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
68
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
79
import com.fasterxml.jackson.module.kotlin.kotlinModule
@@ -11,7 +13,10 @@ import com.github.victools.jsonschema.generator.SchemaGenerator
1113
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder
1214
import com.github.victools.jsonschema.module.jackson.JacksonModule
1315
import com.openai.errors.OpenAIInvalidDataException
16+
import com.openai.models.FunctionDefinition
1417
import com.openai.models.ResponseFormatJsonSchema
18+
import com.openai.models.chat.completions.ChatCompletionTool
19+
import com.openai.models.responses.FunctionTool
1520
import com.openai.models.responses.ResponseFormatTextJsonSchemaConfig
1621
import com.openai.models.responses.ResponseTextConfig
1722

@@ -38,29 +43,41 @@ internal fun <T> responseFormatFromClass(
3843
.jsonSchema(
3944
ResponseFormatJsonSchema.JsonSchema.builder()
4045
.name("json-schema-from-${type.simpleName}")
41-
.schema(JsonValue.fromJsonNode(extractAndValidateSchema(type, localValidation)))
46+
.schema(
47+
JsonValue.fromJsonNode(
48+
validateSchema(extractSchema(type), type, localValidation)
49+
)
50+
)
4251
// Ensure the model's output strictly adheres to this JSON schema. This is the
4352
// essential "ON switch" for Structured Outputs.
4453
.strict(true)
4554
.build()
4655
)
4756
.build()
4857

49-
private fun <T> extractAndValidateSchema(
50-
type: Class<T>,
58+
/**
59+
* Validates the given JSON schema with respect to OpenAI's JSON schema restrictions.
60+
*
61+
* @param schema The JSON schema to be validated.
62+
* @param sourceType The class from which the JSON schema was derived. This is only used in error
63+
* messages.
64+
* @param localValidation Set to [JsonSchemaLocalValidation.YES] to perform the validation. Other
65+
* values will cause validation to be skipped.
66+
*/
67+
@JvmSynthetic
68+
internal fun <T> validateSchema(
69+
schema: ObjectNode,
70+
sourceType: Class<T>,
5171
localValidation: JsonSchemaLocalValidation,
52-
): JsonNode {
53-
val schema = extractSchema(type)
54-
72+
): ObjectNode {
5573
if (localValidation == JsonSchemaLocalValidation.YES) {
5674
val validator = JsonSchemaValidator.create().validate(schema)
5775

5876
require(validator.isValid()) {
59-
"Local validation failed for JSON schema derived from $type:\n" +
77+
"Local validation failed for JSON schema derived from $sourceType:\n" +
6078
validator.errors().joinToString("\n") { " - $it" }
6179
}
6280
}
63-
6481
return schema
6582
}
6683

@@ -77,14 +94,94 @@ internal fun <T> textConfigFromClass(
7794
.format(
7895
ResponseFormatTextJsonSchemaConfig.builder()
7996
.name("json-schema-from-${type.simpleName}")
80-
.schema(JsonValue.fromJsonNode(extractAndValidateSchema(type, localValidation)))
97+
.schema(
98+
JsonValue.fromJsonNode(
99+
validateSchema(extractSchema(type), type, localValidation)
100+
)
101+
)
81102
// Ensure the model's output strictly adheres to this JSON schema. This is the
82103
// essential "ON switch" for Structured Outputs.
83104
.strict(true)
84105
.build()
85106
)
86107
.build()
87108

109+
// "internal" instead of "private" for testing purposes.
110+
internal data class FunctionInfo(
111+
val name: String,
112+
val description: String?,
113+
val schema: ObjectNode,
114+
)
115+
116+
@JvmSynthetic
117+
// "internal" instead of "private" for testing purposes.
118+
internal fun <T> extractFunctionInfo(
119+
parametersType: Class<T>,
120+
localValidation: JsonSchemaLocalValidation,
121+
): FunctionInfo {
122+
val schema = extractSchema(parametersType)
123+
124+
validateSchema(schema, parametersType, localValidation)
125+
126+
// The JSON schema generator ignores the `@JsonTypeName` annotation, so it never sets the "name"
127+
// field at the root of the schema. Respect that annotation here and use it to set the name
128+
// (outside the schema). Fall back to using the simple name of the class.
129+
val name =
130+
parametersType.getAnnotation(JsonTypeName::class.java)?.value ?: parametersType.simpleName
131+
132+
// The JSON schema generator will copy the `@JsonClassDescription` into the schema. If present,
133+
// remove it from the schema so it can be set on the function definition/tool.
134+
val descriptionNode: JsonNode? = schema.remove("description")
135+
val description: String? = descriptionNode?.textValue()
136+
137+
return FunctionInfo(name, description, schema)
138+
}
139+
140+
/**
141+
* Creates a Chat Completions API tool defining a function whose input parameters are derived from
142+
* the fields of a class.
143+
*/
144+
@JvmSynthetic
145+
internal fun <T> functionToolFromClass(
146+
parametersType: Class<T>,
147+
localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES,
148+
): ChatCompletionTool {
149+
val functionInfo = extractFunctionInfo(parametersType, localValidation)
150+
151+
return ChatCompletionTool.builder()
152+
.function(
153+
FunctionDefinition.builder()
154+
.name(functionInfo.name)
155+
.apply { functionInfo.description?.let(::description) }
156+
.parameters(JsonValue.fromJsonNode(functionInfo.schema))
157+
// OpenAI: "Setting strict to true will ensure function calls reliably adhere to the
158+
// function schema, instead of being best effort. We recommend always enabling
159+
// strict mode."
160+
.strict(true)
161+
.build()
162+
)
163+
.build()
164+
}
165+
166+
/**
167+
* Creates a Responses API function tool defining a function whose input parameters are derived from
168+
* the fields of a class.
169+
*/
170+
@JvmSynthetic
171+
internal fun <T> responseFunctionToolFromClass(
172+
parametersType: Class<T>,
173+
localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES,
174+
): FunctionTool {
175+
val functionInfo = extractFunctionInfo(parametersType, localValidation)
176+
177+
return FunctionTool.builder()
178+
.name(functionInfo.name)
179+
.apply { functionInfo.description?.let(::description) }
180+
.parameters(JsonValue.fromJsonNode(functionInfo.schema))
181+
.strict(true)
182+
.build()
183+
}
184+
88185
/**
89186
* Derives a JSON schema from the structure of an arbitrary Java class.
90187
*
@@ -93,7 +190,7 @@ internal fun <T> textConfigFromClass(
93190
* thrown and any recorded validation errors can be inspected at leisure by the tests.
94191
*/
95192
@JvmSynthetic
96-
internal fun <T> extractSchema(type: Class<T>): JsonNode {
193+
internal fun <T> extractSchema(type: Class<T>): ObjectNode {
97194
val configBuilder =
98195
SchemaGeneratorConfigBuilder(
99196
com.github.victools.jsonschema.generator.SchemaVersion.DRAFT_2020_12,
@@ -130,3 +227,10 @@ internal fun <T> responseTypeFromJson(json: String, responseType: Class<T>): T =
130227
// sensitive data are not exposed in logs.
131228
throw OpenAIInvalidDataException("Error parsing JSON: $json", e)
132229
}
230+
231+
/**
232+
* Converts any object into a JSON-formatted string. For `Object` types (other than strings and
233+
* boxed primitives) a JSON object is created with its fields and values set from the fields of the
234+
* object.
235+
*/
236+
@JvmSynthetic internal fun toJsonString(obj: Any): String = MAPPER.writeValueAsString(obj)

openai-java-core/src/main/kotlin/com/openai/models/chat/completions/ChatCompletionCreateParams.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import com.openai.core.Params
2525
import com.openai.core.allMaxBy
2626
import com.openai.core.checkKnown
2727
import com.openai.core.checkRequired
28+
import com.openai.core.functionToolFromClass
2829
import com.openai.core.getOrThrow
2930
import com.openai.core.http.Headers
3031
import com.openai.core.http.QueryParams
@@ -1536,6 +1537,21 @@ private constructor(
15361537
*/
15371538
fun addTool(tool: ChatCompletionTool) = apply { body.addTool(tool) }
15381539

1540+
/**
1541+
* Adds a single [ChatCompletionTool] to [tools] where the JSON schema describing the
1542+
* function parameters is derived from the fields of a given class. Local validation of that
1543+
* JSON schema can be performed to check if the schema is likely to pass remote validation
1544+
* by the AI model. By default, local validation is enabled; disable it by setting
1545+
* [localValidation] to [JsonSchemaLocalValidation.NO].
1546+
*
1547+
* @see addTool
1548+
*/
1549+
@JvmOverloads
1550+
fun <T : Any> addTool(
1551+
functionParametersType: Class<T>,
1552+
localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES,
1553+
) = apply { addTool(functionToolFromClass(functionParametersType, localValidation)) }
1554+
15391555
/**
15401556
* An integer between 0 and 20 specifying the number of most likely tokens to return at each
15411557
* token position, each with an associated log probability. `logprobs` must be set to `true`

openai-java-core/src/main/kotlin/com/openai/models/chat/completions/ChatCompletionMessageToolCall.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.openai.core.JsonField
1111
import com.openai.core.JsonMissing
1212
import com.openai.core.JsonValue
1313
import com.openai.core.checkRequired
14+
import com.openai.core.responseTypeFromJson
1415
import com.openai.errors.OpenAIInvalidDataException
1516
import java.util.Collections
1617
import java.util.Objects
@@ -258,6 +259,18 @@ private constructor(
258259
*/
259260
fun arguments(): String = arguments.getRequired("arguments")
260261

262+
/**
263+
* Gets the arguments to the function call, converting the values from the model in JSON
264+
* format to an instance of a class that holds those values. The class must previously have
265+
* been used to define the JSON schema for the function definition's parameters, so that the
266+
* JSON corresponds to structure of the given class.
267+
*
268+
* @see ChatCompletionCreateParams.Builder.addTool
269+
* @see arguments
270+
*/
271+
fun <T> arguments(functionParametersType: Class<T>): T =
272+
responseTypeFromJson(arguments(), functionParametersType)
273+
261274
/**
262275
* The name of the function to call.
263276
*

openai-java-core/src/main/kotlin/com/openai/models/chat/completions/ChatCompletionToolMessageParam.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.openai.core.JsonValue
2222
import com.openai.core.allMaxBy
2323
import com.openai.core.checkRequired
2424
import com.openai.core.getOrThrow
25+
import com.openai.core.toJsonString
2526
import com.openai.errors.OpenAIInvalidDataException
2627
import java.util.Collections
2728
import java.util.Objects
@@ -146,6 +147,14 @@ private constructor(
146147
/** Alias for calling [content] with `Content.ofText(text)`. */
147148
fun content(text: String) = content(Content.ofText(text))
148149

150+
/**
151+
* Sets the content to text representing the JSON serialized form of a given object. This is
152+
* useful when passing data that is the result of a function call.
153+
*
154+
* @see content
155+
*/
156+
fun contentAsJson(functionResult: Any) = content(toJsonString(functionResult))
157+
149158
/**
150159
* Alias for calling [content] with `Content.ofArrayOfContentParts(arrayOfContentParts)`.
151160
*/

openai-java-core/src/main/kotlin/com/openai/models/chat/completions/StructuredChatCompletionCreateParams.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,13 @@ internal constructor(
532532
/** @see ChatCompletionCreateParams.Builder.addTool */
533533
fun addTool(tool: ChatCompletionTool) = apply { paramsBuilder.addTool(tool) }
534534

535+
/** @see ChatCompletionCreateParams.Builder.addTool */
536+
@JvmOverloads
537+
fun <T : Any> addTool(
538+
functionParametersType: Class<T>,
539+
localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES,
540+
) = apply { paramsBuilder.addTool(functionParametersType, localValidation) }
541+
535542
/** @see ChatCompletionCreateParams.Builder.topLogprobs */
536543
fun topLogprobs(topLogprobs: Long?) = apply { paramsBuilder.topLogprobs(topLogprobs) }
537544

openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseCreateParams.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.openai.core.checkRequired
2828
import com.openai.core.getOrThrow
2929
import com.openai.core.http.Headers
3030
import com.openai.core.http.QueryParams
31+
import com.openai.core.responseFunctionToolFromClass
3132
import com.openai.core.toImmutable
3233
import com.openai.errors.OpenAIInvalidDataException
3334
import com.openai.models.ChatModel
@@ -926,6 +927,23 @@ private constructor(
926927
body.addFileSearchTool(vectorStoreIds)
927928
}
928929

930+
/**
931+
* Adds a single [FunctionTool] where the JSON schema describing the function parameters is
932+
* derived from the fields of a given class. Local validation of that JSON schema can be
933+
* performed to check if the schema is likely to pass remote validation by the AI model. By
934+
* default, local validation is enabled; disable it by setting [localValidation] to
935+
* [JsonSchemaLocalValidation.NO].
936+
*
937+
* @see addTool
938+
*/
939+
@JvmOverloads
940+
fun <T> addTool(
941+
functionParametersType: Class<T>,
942+
localValidation: JsonSchemaLocalValidation = JsonSchemaLocalValidation.YES,
943+
) = apply {
944+
body.addTool(responseFunctionToolFromClass(functionParametersType, localValidation))
945+
}
946+
929947
/** Alias for calling [addTool] with `Tool.ofWebSearch(webSearch)`. */
930948
fun addTool(webSearch: WebSearchTool) = apply { body.addTool(webSearch) }
931949

0 commit comments

Comments
 (0)