Skip to content

Commit e123fdd

Browse files
damoTomerAberbach
authored andcommitted
feat(client): type safe structured outputs (#463)
* structured-outputs: updates and more unit tests. * structured-outputs: repair after bad merge. * structured-outputs: local validation, unit tests and documentation * structured-outputs: changes from code review * structured-outputs: added 'strict' flag * structured-outputs: support for Responses API, review changes * structured-outputs: removed support for Responses params Builder.body function * structured-outputs: extra docs and simpler code * docs: swap primary structured outputs example --------- Co-authored-by: Tomer Aberbach <[email protected]>
1 parent 567c86e commit e123fdd

30 files changed

+7016
-25
lines changed

README.md

Lines changed: 202 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ OpenAIClient client = OpenAIOkHttpClient.builder()
286286

287287
The SDK provides conveniences for streamed chat completions. A
288288
[`ChatCompletionAccumulator`](openai-java-core/src/main/kotlin/com/openai/helpers/ChatCompletionAccumulator.kt)
289-
can record the stream of chat completion chunks in the response as they are processed and accumulate
289+
can record the stream of chat completion chunks in the response as they are processed and accumulate
290290
a [`ChatCompletion`](openai-java-core/src/main/kotlin/com/openai/models/chat/completions/ChatCompletion.kt)
291291
object similar to that which would have been returned by the non-streaming API.
292292

@@ -334,6 +334,205 @@ client.chat()
334334
ChatCompletion chatCompletion = chatCompletionAccumulator.chatCompletion();
335335
```
336336

337+
## Structured outputs with JSON schemas
338+
339+
Open AI [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat)
340+
is a feature that ensures that the model will always generate responses that adhere to a supplied
341+
[JSON schema](https://json-schema.org/overview/what-is-jsonschema).
342+
343+
A JSON schema can be defined by creating a
344+
[`ResponseFormatJsonSchema`](openai-java-core/src/main/kotlin/com/openai/models/ResponseFormatJsonSchema.kt)
345+
and setting it on the input parameters. However, for greater convenience, a JSON schema can instead
346+
be derived automatically from the structure of an arbitrary Java class. The JSON content from the
347+
response will then be converted automatically to an instance of that Java class. A full, working
348+
example of the use of Structured Outputs with arbitrary Java classes can be seen in
349+
[`StructuredOutputsExample`](openai-java-example/src/main/java/com/openai/example/StructuredOutputsExample.java).
350+
351+
Java classes can contain fields declared to be instances of other classes and can use collections:
352+
353+
```java
354+
class Person {
355+
public String name;
356+
public int birthYear;
357+
}
358+
359+
class Book {
360+
public String title;
361+
public Person author;
362+
public int publicationYear;
363+
}
364+
365+
class BookList {
366+
public List<Book> books;
367+
}
368+
```
369+
370+
Pass the top-level class—`BookList` in this example—to `responseFormat(Class<T>)` when building the
371+
parameters and then access an instance of `BookList` from the generated message content in the
372+
response:
373+
374+
```java
375+
import com.openai.models.ChatModel;
376+
import com.openai.models.chat.completions.ChatCompletionCreateParams;
377+
import com.openai.models.chat.completions.StructuredChatCompletionCreateParams;
378+
379+
StructuredChatCompletionCreateParams<BookList> params = ChatCompletionCreateParams.builder()
380+
.addUserMessage("List some famous late twentieth century novels.")
381+
.model(ChatModel.GPT_4_1)
382+
.responseFormat(BookList.class)
383+
.build();
384+
385+
client.chat().completions().create(params).choices().stream()
386+
.flatMap(choice -> choice.message().content().stream())
387+
.flatMap(bookList -> bookList.books.stream())
388+
.forEach(book -> System.out.println(book.title + " by " + book.author.name));
389+
```
390+
391+
You can start building the parameters with an instance of
392+
[`ChatCompletionCreateParams.Builder`](openai-java-core/src/main/kotlin/com/openai/models/chat/completions/ChatCompletionCreateParams.kt)
393+
or
394+
[`StructuredChatCompletionCreateParams.Builder`](openai-java-core/src/main/kotlin/com/openai/models/chat/completions/StructuredChatCompletionCreateParams.kt).
395+
If you start with the former (which allows for more compact code) the builder type will change to
396+
the latter when `ChatCompletionCreateParams.Builder.responseFormat(Class<T>)` is called.
397+
398+
If a field in a class is optional and does not require a defined value, you can represent this using
399+
the [`java.util.Optional`](https://docs.oracle.com/javase/8/docs/api/java/util/Optional.html) class.
400+
It is up to the AI model to decide whether to provide a value for that field or leave it empty.
401+
402+
```java
403+
import java.util.Optional;
404+
405+
class Book {
406+
public String title;
407+
public Person author;
408+
public int publicationYear;
409+
public Optional<String> isbn;
410+
}
411+
```
412+
413+
Generic type information for fields is retained in the class's metadata, but _generic type erasure_
414+
applies in other scopes. While, for example, a JSON schema defining an array of books can be derived
415+
from the `BookList.books` field with type `List<Book>`, a valid JSON schema cannot be derived from a
416+
local variable of that same type, so the following will _not_ work:
417+
418+
```java
419+
List<Book> books = new ArrayList<>();
420+
421+
StructuredChatCompletionCreateParams<List<Book>> params = ChatCompletionCreateParams.builder()
422+
.responseFormat(books.getClass())
423+
// ...
424+
.build();
425+
```
426+
427+
If an error occurs while converting a JSON response to an instance of a Java class, the error
428+
message will include the JSON response to assist in diagnosis. For instance, if the response is
429+
truncated, the JSON data will be incomplete and cannot be converted to a class instance. If your
430+
JSON response may contain sensitive information, avoid logging it directly, or ensure that you
431+
redact any sensitive details from the error message.
432+
433+
### Local JSON schema validation
434+
435+
Structured Outputs supports a
436+
[subset](https://platform.openai.com/docs/guides/structured-outputs#supported-schemas) of the JSON
437+
Schema language. Schemas are generated automatically from classes to align with this subset.
438+
However, due to the inherent structure of the classes, the generated schema may still violate
439+
certain OpenAI schema restrictions, such as exceeding the maximum nesting depth or utilizing
440+
unsupported data types.
441+
442+
To facilitate compliance, the method `responseFormat(Class<T>)` performs a validation check on the
443+
schema derived from the specified class. This validation ensures that all restrictions are adhered
444+
to. If any issues are detected, an exception will be thrown, providing a detailed message outlining
445+
the reasons for the validation failure.
446+
447+
- **Local Validation**: The validation process occurs locally, meaning no requests are sent to the
448+
remote AI model. If the schema passes local validation, it is likely to pass remote validation as
449+
well.
450+
- **Remote Validation**: The remote AI model will conduct its own validation upon receiving the JSON
451+
schema in the request.
452+
- **Version Compatibility**: There may be instances where local validation fails while remote
453+
validation succeeds. This can occur if the SDK version is outdated compared to the restrictions
454+
enforced by the remote AI model.
455+
- **Disabling Local Validation**: If you encounter compatibility issues and wish to bypass local
456+
validation, you can disable it by passing
457+
[`JsonSchemaLocalValidation.NO`](openai-java-core/src/main/kotlin/com/openai/core/JsonSchemaLocalValidation.kt)
458+
to the `responseFormat(Class<T>, JsonSchemaLocalValidation)` method when building the parameters.
459+
(The default value for this parameter is `JsonSchemaLocalValidation.YES`.)
460+
461+
```java
462+
import com.openai.core.JsonSchemaLocalValidation;
463+
import com.openai.models.ChatModel;
464+
import com.openai.models.chat.completions.ChatCompletionCreateParams;
465+
import com.openai.models.chat.completions.StructuredChatCompletionCreateParams;
466+
467+
StructuredChatCompletionCreateParams<BookList> params = ChatCompletionCreateParams.builder()
468+
.addUserMessage("List some famous late twentieth century novels.")
469+
.model(ChatModel.GPT_4_1)
470+
.responseFormat(BookList.class, JsonSchemaLocalValidation.NO)
471+
.build();
472+
```
473+
474+
By following these guidelines, you can ensure that your structured outputs conform to the necessary
475+
schema requirements and minimize the risk of remote validation errors.
476+
477+
### Usage with the Responses API
478+
479+
_Structured Outputs_ are also supported for the Responses API. The usage is the same as described
480+
except where the Responses API differs slightly from the Chat Completions API. Pass the top-level
481+
class to `text(Class<T>)` when building the parameters and then access an instance of the class from
482+
the generated message content in the response.
483+
484+
You can start building the parameters with an instance of
485+
[`ResponseCreateParams.Builder`](openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseCreateParams.kt)
486+
or
487+
[`StructuredResponseCreateParams.Builder`](openai-java-core/src/main/kotlin/com/openai/models/responses/StructuredResponseCreateParams.kt).
488+
If you start with the former (which allows for more compact code) the builder type will change to
489+
the latter when `ResponseCreateParams.Builder.text(Class<T>)` is called.
490+
491+
For a full example of the usage of _Structured Outputs_ with the Responses API, see
492+
[`ResponsesStructuredOutputsExample`](openai-java-example/src/main/java/com/openai/example/ResponsesStructuredOutputsExample.java).
493+
494+
### Annotating classes and JSON schemas
495+
496+
You can use annotations to add further information to the JSON schema derived from your Java
497+
classes, or to exclude individual fields from the schema. Details from annotations captured in the
498+
JSON schema may be used by the AI model to improve its response. The SDK supports the use of
499+
[Jackson Databind](https://github.com/FasterXML/jackson-databind) annotations.
500+
501+
```java
502+
import com.fasterxml.jackson.annotation.JsonClassDescription;
503+
import com.fasterxml.jackson.annotation.JsonIgnore;
504+
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
505+
506+
class Person {
507+
@JsonPropertyDescription("The first name and surname of the person")
508+
public String name;
509+
public int birthYear;
510+
@JsonPropertyDescription("The year the person died, or 'present' if the person is living.")
511+
public String deathYear;
512+
}
513+
514+
@JsonClassDescription("The details of one published book")
515+
class Book {
516+
public String title;
517+
public Person author;
518+
@JsonPropertyDescription("The year in which the book was first published.")
519+
public int publicationYear;
520+
@JsonIgnore public String genre;
521+
}
522+
523+
class BookList {
524+
public List<Book> books;
525+
}
526+
```
527+
528+
- Use `@JsonClassDescription` to add a detailed description to a class.
529+
- Use `@JsonPropertyDescription` to add a detailed description to a field of a class.
530+
- Use `@JsonIgnore` to omit a field of a class from the generated JSON schema.
531+
532+
If you use `@JsonProperty(required = false)`, the `false` value will be ignored. OpenAI JSON schemas
533+
must mark all properties as _required_, so the schema generated from your Java classes will respect
534+
that restriction and ignore any annotation that would violate it.
535+
337536
## File uploads
338537

339538
The SDK defines methods that accept files.
@@ -652,7 +851,7 @@ If the SDK threw an exception, but you're _certain_ the version is compatible, t
652851
653852
## Microsoft Azure
654853

655-
To use this library with [Azure OpenAI](https://learn.microsoft.com/azure/ai-services/openai/overview), use the same
854+
To use this library with [Azure OpenAI](https://learn.microsoft.com/azure/ai-services/openai/overview), use the same
656855
OpenAI client builder but with the Azure-specific configuration.
657856

658857
```java
@@ -665,7 +864,7 @@ OpenAIClient client = OpenAIOkHttpClient.builder()
665864
.build();
666865
```
667866

668-
See the complete Azure OpenAI example in the [`openai-java-example`](openai-java-example/src/main/java/com/openai/example/AzureEntraIdExample.java) directory. The other examples in the directory also work with Azure as long as the client is configured to use it.
867+
See the complete Azure OpenAI example in the [`openai-java-example`](openai-java-example/src/main/java/com/openai/example/AzureEntraIdExample.java) directory. The other examples in the directory also work with Azure as long as the client is configured to use it.
669868

670869
## Network options
671870

openai-java-core/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ dependencies {
2727
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2")
2828
implementation("org.apache.httpcomponents.core5:httpcore5:5.2.4")
2929
implementation("org.apache.httpcomponents.client5:httpclient5:5.3.1")
30+
implementation("com.github.victools:jsonschema-generator:4.38.0")
31+
implementation("com.github.victools:jsonschema-module-jackson:4.38.0")
3032

3133
testImplementation(kotlin("test"))
3234
testImplementation(project(":openai-java-client-okhttp"))
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.openai.core
2+
3+
/**
4+
* Options for local validation of JSON schemas derived from arbitrary classes before a request is
5+
* executed.
6+
*/
7+
enum class JsonSchemaLocalValidation {
8+
/**
9+
* Validate the JSON schema locally before the request is executed. The remote AI model will
10+
* also validate the JSON schema.
11+
*/
12+
YES,
13+
14+
/**
15+
* Do not validate the JSON schema locally before the request is executed. The remote AI model
16+
* will always validate the JSON schema.
17+
*/
18+
NO,
19+
}

0 commit comments

Comments
 (0)