diff --git a/README.md b/README.md index 67ea87d5b..90f311022 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,53 @@ client.chat() ChatCompletion chatCompletion = chatCompletionAccumulator.chatCompletion(); ``` +The SDK provides conveniences for streamed responses. A +[`ResponseAccumulator`](openai-java-core/src/main/kotlin/com/openai/helpers/ResponseAccumulator.kt) +can record the stream of response events as they are processed and accumulate a +[`Response`](openai-java-core/src/main/kotlin/com/openai/models/responses/Response.kt) +object similar to that which would have been returned by the non-streaming API. + +For a synchronous response add a +[`Stream.peek()`](https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html#peek-java.util.function.Consumer-) +call to the stream pipeline to accumulate each event: + +```java +import com.openai.core.http.StreamResponse; +import com.openai.helpers.ResponseAccumulator; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseStreamEvent; + +ResponseAccumulator responseAccumulator = ResponseAccumulator.create(); + +try (StreamResponse streamResponse = + client.responses().createStreaming(createParams)) { + streamResponse.stream() + .peek(responseAccumulator::accumulate) + .flatMap(event -> event.outputTextDelta().stream()) + .forEach(textEvent -> System.out.print(textEvent.delta())); +} + +Response response = responseAccumulator.response(); +``` + +For an asynchronous response, add the `ResponseAccumulator` to the `subscribe()` call: + +```java +import com.openai.helpers.ResponseAccumulator; +import com.openai.models.responses.Response; + +ResponseAccumulator responseAccumulator = ResponseAccumulator.create(); + +client.responses() + .createStreaming(createParams) + .subscribe(event -> responseAccumulator.accumulate(event) + .outputTextDelta().ifPresent(textEvent -> System.out.print(textEvent.delta()))) + .onCompleteFuture() + .join(); + +Response response = responseAccumulator.response(); +``` + ## Structured outputs with JSON schemas Open AI [Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat) @@ -527,11 +574,16 @@ For a full example of the usage of _Structured Outputs_ with Streaming and the C see [`StructuredOutputsStreamingExample`](openai-java-example/src/main/java/com/openai/example/StructuredOutputsStreamingExample.java). -At present, there is no accumulator for streaming responses using the Responses API. It is still -possible to derive a JSON schema from a Java class and create a streaming response for a -[`StructuredResponseCreateParams`](openai-java-core/src/main/kotlin/com/openai/models/responses/StructuredResponseCreateParams.kt) -object, but there is no helper for deserialization of the response to an instance of that Java -class. +With the Responses API, accumulate events while streaming using the +[`ResponseAccumulator`](openai-java-core/src/main/kotlin/com/openai/helpers/ResponseAccumulator.kt). +Once accumulated, use `ResponseAccumulator.response(Class)` to convert the accumulated `Response` +into a +[`StructuredResponse`](openai-java-core/src/main/kotlin/com/openai/models/responses/StructuredResponse.kt). +The [`StructuredResponse`] can then automatically deserialize the JSON strings into instances of +your Java class. + +For a full example of the usage of _Structured Outputs_ with Streaming and the Responses API, see +[`ResponsesStructuredOutputsStreamingExample`](openai-java-example/src/main/java/com/openai/example/ResponsesStructuredOutputsStreamingExample.java). ### Defining JSON schema properties diff --git a/openai-java-core/src/main/kotlin/com/openai/helpers/ResponseAccumulator.kt b/openai-java-core/src/main/kotlin/com/openai/helpers/ResponseAccumulator.kt new file mode 100644 index 000000000..485442bad --- /dev/null +++ b/openai-java-core/src/main/kotlin/com/openai/helpers/ResponseAccumulator.kt @@ -0,0 +1,325 @@ +package com.openai.helpers + +import com.openai.errors.OpenAIInvalidDataException +import com.openai.models.responses.Response +import com.openai.models.responses.ResponseAudioDeltaEvent +import com.openai.models.responses.ResponseAudioDoneEvent +import com.openai.models.responses.ResponseAudioTranscriptDeltaEvent +import com.openai.models.responses.ResponseAudioTranscriptDoneEvent +import com.openai.models.responses.ResponseCodeInterpreterCallCodeDeltaEvent +import com.openai.models.responses.ResponseCodeInterpreterCallCodeDoneEvent +import com.openai.models.responses.ResponseCodeInterpreterCallCompletedEvent +import com.openai.models.responses.ResponseCodeInterpreterCallInProgressEvent +import com.openai.models.responses.ResponseCodeInterpreterCallInterpretingEvent +import com.openai.models.responses.ResponseCompletedEvent +import com.openai.models.responses.ResponseContentPartAddedEvent +import com.openai.models.responses.ResponseContentPartDoneEvent +import com.openai.models.responses.ResponseCreatedEvent +import com.openai.models.responses.ResponseErrorEvent +import com.openai.models.responses.ResponseFailedEvent +import com.openai.models.responses.ResponseFileSearchCallCompletedEvent +import com.openai.models.responses.ResponseFileSearchCallInProgressEvent +import com.openai.models.responses.ResponseFileSearchCallSearchingEvent +import com.openai.models.responses.ResponseFunctionCallArgumentsDeltaEvent +import com.openai.models.responses.ResponseFunctionCallArgumentsDoneEvent +import com.openai.models.responses.ResponseImageGenCallCompletedEvent +import com.openai.models.responses.ResponseImageGenCallGeneratingEvent +import com.openai.models.responses.ResponseImageGenCallInProgressEvent +import com.openai.models.responses.ResponseImageGenCallPartialImageEvent +import com.openai.models.responses.ResponseInProgressEvent +import com.openai.models.responses.ResponseIncompleteEvent +import com.openai.models.responses.ResponseMcpCallArgumentsDeltaEvent +import com.openai.models.responses.ResponseMcpCallArgumentsDoneEvent +import com.openai.models.responses.ResponseMcpCallCompletedEvent +import com.openai.models.responses.ResponseMcpCallFailedEvent +import com.openai.models.responses.ResponseMcpCallInProgressEvent +import com.openai.models.responses.ResponseMcpListToolsCompletedEvent +import com.openai.models.responses.ResponseMcpListToolsFailedEvent +import com.openai.models.responses.ResponseMcpListToolsInProgressEvent +import com.openai.models.responses.ResponseOutputItemAddedEvent +import com.openai.models.responses.ResponseOutputItemDoneEvent +import com.openai.models.responses.ResponseOutputTextAnnotationAddedEvent +import com.openai.models.responses.ResponseQueuedEvent +import com.openai.models.responses.ResponseReasoningDeltaEvent +import com.openai.models.responses.ResponseReasoningDoneEvent +import com.openai.models.responses.ResponseReasoningSummaryDeltaEvent +import com.openai.models.responses.ResponseReasoningSummaryDoneEvent +import com.openai.models.responses.ResponseReasoningSummaryPartAddedEvent +import com.openai.models.responses.ResponseReasoningSummaryPartDoneEvent +import com.openai.models.responses.ResponseReasoningSummaryTextDeltaEvent +import com.openai.models.responses.ResponseReasoningSummaryTextDoneEvent +import com.openai.models.responses.ResponseRefusalDeltaEvent +import com.openai.models.responses.ResponseRefusalDoneEvent +import com.openai.models.responses.ResponseStreamEvent +import com.openai.models.responses.ResponseTextDeltaEvent +import com.openai.models.responses.ResponseTextDoneEvent +import com.openai.models.responses.ResponseWebSearchCallCompletedEvent +import com.openai.models.responses.ResponseWebSearchCallInProgressEvent +import com.openai.models.responses.ResponseWebSearchCallSearchingEvent +import com.openai.models.responses.StructuredResponse + +/** + * An accumulator that constructs a [Response] from a sequence of streamed events. Pass all events + * to [accumulate] and then call [response] to get the final accumulated response. The final + * `Response` will be similar to what would have been received had the non-streaming API been used. + * + * A [ResponseAccumulator] may only be used to accumulate _one_ response. To accumulate another + * response, create another instance of `ResponseAccumulator`. + */ +class ResponseAccumulator private constructor() { + + /** + * The response accumulated from the event stream. This is set when a terminal event is + * accumulated. That single event carries all the response details. + */ + private var response: Response? = null + + companion object { + @JvmStatic fun create() = ResponseAccumulator() + } + + /** + * Gets the final accumulated response. Until the last event has been accumulated, a [Response] + * will not be available. Wait until all events have been handled by [accumulate] before calling + * this method. + * + * @throws IllegalStateException If called before the stream has been completed. + */ + fun response() = checkNotNull(response) { "Completed response is not yet received." } + + /** + * Gets the final accumulated response with support for structured outputs. Until the last event + * has been accumulated, a [StructuredResponse] will not be available. Wait until all events + * have been handled by [accumulate] before calling this method. See that method for more + * details on how the last event is detected. See the + * [SDK documentation](https://github.com/openai/openai-java/#usage-with-streaming) for more + * details and example code. + * + * @param responseType The Java class from which the JSON schema in the request was derived. The + * output JSON conforming to that schema can be converted automatically back to an instance of + * that Java class by the [StructuredResponse]. + * @throws IllegalStateException If called before the last event has been accumulated. + * @throws OpenAIInvalidDataException If the JSON data cannot be parsed to an instance of the + * [responseType] class. + */ + fun response(responseType: Class) = StructuredResponse(responseType, response()) + + /** + * Accumulates a streamed event and uses it to construct a [Response]. When all events have been + * accumulated, the response can be retrieved by calling [response]. The last event is detected + * if one of `ResponseCompletedEvent`, `ResponseIncompleteEvent`, or `ResponseFailedEvent` is + * accumulated. After that event, no more events are expected. + * + * @return The given [event] for convenience, such as when chaining method calls. + * @throws IllegalStateException If [accumulate] is called again after the last event has been + * accumulated. A [ResponseAccumulator] can only be used to accumulate a single [Response]. + */ + fun accumulate(event: ResponseStreamEvent): ResponseStreamEvent { + check(response == null) { "Response has already been completed." } + + event.accept( + object : ResponseStreamEvent.Visitor { + // -------------------------------------------------------------------------------- + // The following events _all_ have a `response` property. + + override fun visitCreated(created: ResponseCreatedEvent) { + // The initial response (on creation) has no content, so it is not stored. + } + + override fun visitCompleted(completed: ResponseCompletedEvent) { + response = completed.response() + } + + override fun visitInProgress(inProgress: ResponseInProgressEvent) { + // An in-progress response is not complete, so it is not stored. + } + + override fun visitQueued(queued: ResponseQueuedEvent) { + // A queued response that is awaiting processing is not complete, so it is not + // stored. + } + + override fun visitFailed(failed: ResponseFailedEvent) { + // TODO: Confirm that this is a "terminal" event and will occur _instead of_ + // `ResponseCompletedEvent` or `ResponseIncompleteEvent`. + // Store the response so the reason for the failure can be interrogated. + response = failed.response() + } + + override fun visitIncomplete(incomplete: ResponseIncompleteEvent) { + // TODO: Confirm that this is a "terminal" event and will occur _instead of_ + // `ResponseCompletedEvent` or `ResponseFailedEvent`. + // Store the response so the reason for the incompleteness can be interrogated. + response = incomplete.response() + } + + // -------------------------------------------------------------------------------- + // The following events do _not_ have a `Response` property. + + override fun visitAudioDelta(audioDelta: ResponseAudioDeltaEvent) {} + + override fun visitAudioDone(audioDone: ResponseAudioDoneEvent) {} + + override fun visitAudioTranscriptDelta( + audioTranscriptDelta: ResponseAudioTranscriptDeltaEvent + ) {} + + override fun visitAudioTranscriptDone( + audioTranscriptDone: ResponseAudioTranscriptDoneEvent + ) {} + + override fun visitCodeInterpreterCallCodeDelta( + codeInterpreterCallCodeDelta: ResponseCodeInterpreterCallCodeDeltaEvent + ) {} + + override fun visitCodeInterpreterCallCodeDone( + codeInterpreterCallCodeDone: ResponseCodeInterpreterCallCodeDoneEvent + ) {} + + override fun visitCodeInterpreterCallCompleted( + codeInterpreterCallCompleted: ResponseCodeInterpreterCallCompletedEvent + ) {} + + override fun visitCodeInterpreterCallInProgress( + codeInterpreterCallInProgress: ResponseCodeInterpreterCallInProgressEvent + ) {} + + override fun visitCodeInterpreterCallInterpreting( + codeInterpreterCallInterpreting: ResponseCodeInterpreterCallInterpretingEvent + ) {} + + override fun visitContentPartAdded( + contentPartAdded: ResponseContentPartAddedEvent + ) {} + + override fun visitContentPartDone(contentPartDone: ResponseContentPartDoneEvent) {} + + override fun visitError(error: ResponseErrorEvent) {} + + override fun visitFileSearchCallCompleted( + fileSearchCallCompleted: ResponseFileSearchCallCompletedEvent + ) {} + + override fun visitFileSearchCallInProgress( + fileSearchCallInProgress: ResponseFileSearchCallInProgressEvent + ) {} + + override fun visitFileSearchCallSearching( + fileSearchCallSearching: ResponseFileSearchCallSearchingEvent + ) {} + + override fun visitFunctionCallArgumentsDelta( + functionCallArgumentsDelta: ResponseFunctionCallArgumentsDeltaEvent + ) {} + + override fun visitFunctionCallArgumentsDone( + functionCallArgumentsDone: ResponseFunctionCallArgumentsDoneEvent + ) {} + + override fun visitOutputItemAdded(outputItemAdded: ResponseOutputItemAddedEvent) {} + + override fun visitOutputItemDone(outputItemDone: ResponseOutputItemDoneEvent) {} + + override fun visitReasoningSummaryPartAdded( + reasoningSummaryPartAdded: ResponseReasoningSummaryPartAddedEvent + ) {} + + override fun visitReasoningSummaryPartDone( + reasoningSummaryPartDone: ResponseReasoningSummaryPartDoneEvent + ) {} + + override fun visitReasoningSummaryTextDelta( + reasoningSummaryTextDelta: ResponseReasoningSummaryTextDeltaEvent + ) {} + + override fun visitReasoningSummaryTextDone( + reasoningSummaryTextDone: ResponseReasoningSummaryTextDoneEvent + ) {} + + override fun visitRefusalDelta(refusalDelta: ResponseRefusalDeltaEvent) {} + + override fun visitRefusalDone(refusalDone: ResponseRefusalDoneEvent) {} + + override fun visitOutputTextDelta(outputTextDelta: ResponseTextDeltaEvent) {} + + override fun visitOutputTextDone(outputTextDone: ResponseTextDoneEvent) {} + + override fun visitWebSearchCallCompleted( + webSearchCallCompleted: ResponseWebSearchCallCompletedEvent + ) {} + + override fun visitWebSearchCallInProgress( + webSearchCallInProgress: ResponseWebSearchCallInProgressEvent + ) {} + + override fun visitWebSearchCallSearching( + webSearchCallSearching: ResponseWebSearchCallSearchingEvent + ) {} + + override fun visitImageGenerationCallCompleted( + imageGenerationCallCompleted: ResponseImageGenCallCompletedEvent + ) {} + + override fun visitImageGenerationCallGenerating( + imageGenerationCallGenerating: ResponseImageGenCallGeneratingEvent + ) {} + + override fun visitImageGenerationCallInProgress( + imageGenerationCallInProgress: ResponseImageGenCallInProgressEvent + ) {} + + override fun visitImageGenerationCallPartialImage( + imageGenerationCallPartialImage: ResponseImageGenCallPartialImageEvent + ) {} + + override fun visitMcpCallArgumentsDelta( + mcpCallArgumentsDelta: ResponseMcpCallArgumentsDeltaEvent + ) {} + + override fun visitMcpCallArgumentsDone( + mcpCallArgumentsDone: ResponseMcpCallArgumentsDoneEvent + ) {} + + override fun visitMcpCallCompleted( + mcpCallCompleted: ResponseMcpCallCompletedEvent + ) {} + + override fun visitMcpCallFailed(mcpCallFailed: ResponseMcpCallFailedEvent) {} + + override fun visitMcpCallInProgress( + mcpCallInProgress: ResponseMcpCallInProgressEvent + ) {} + + override fun visitMcpListToolsCompleted( + mcpListToolsCompleted: ResponseMcpListToolsCompletedEvent + ) {} + + override fun visitMcpListToolsFailed( + mcpListToolsFailed: ResponseMcpListToolsFailedEvent + ) {} + + override fun visitMcpListToolsInProgress( + mcpListToolsInProgress: ResponseMcpListToolsInProgressEvent + ) {} + + override fun visitOutputTextAnnotationAdded( + outputTextAnnotationAdded: ResponseOutputTextAnnotationAddedEvent + ) {} + + override fun visitReasoningDelta(reasoningDelta: ResponseReasoningDeltaEvent) {} + + override fun visitReasoningDone(reasoningDone: ResponseReasoningDoneEvent) {} + + override fun visitReasoningSummaryDelta( + reasoningSummaryDelta: ResponseReasoningSummaryDeltaEvent + ) {} + + override fun visitReasoningSummaryDone( + reasoningSummaryDone: ResponseReasoningSummaryDoneEvent + ) {} + } + ) + + return event + } +} diff --git a/openai-java-core/src/test/kotlin/com/openai/helpers/ResponseAccumulatorTest.kt b/openai-java-core/src/test/kotlin/com/openai/helpers/ResponseAccumulatorTest.kt new file mode 100644 index 000000000..81d0b833f --- /dev/null +++ b/openai-java-core/src/test/kotlin/com/openai/helpers/ResponseAccumulatorTest.kt @@ -0,0 +1,166 @@ +package com.openai.helpers + +import com.openai.core.JsonNull +import com.openai.models.ResponsesModel +import com.openai.models.responses.Response +import com.openai.models.responses.ResponseCompletedEvent +import com.openai.models.responses.ResponseCreatedEvent +import com.openai.models.responses.ResponseFailedEvent +import com.openai.models.responses.ResponseInProgressEvent +import com.openai.models.responses.ResponseIncompleteEvent +import com.openai.models.responses.ResponseOutputItem +import com.openai.models.responses.ResponseOutputMessage +import com.openai.models.responses.ResponseOutputText +import com.openai.models.responses.ResponseStreamEvent +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatNoException +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test + +internal class ResponseAccumulatorTest { + + @Test + fun responseBeforeAccumulation() { + val accumulator = ResponseAccumulator.create() + + assertThatThrownBy { accumulator.response() } + .isExactlyInstanceOf(IllegalStateException::class.java) + .hasMessage("Completed response is not yet received.") + } + + @Test + fun structuredResponseBeforeAccumulation() { + val accumulator = ResponseAccumulator.create() + + assertThatThrownBy { accumulator.response(String::class.java) } + .isExactlyInstanceOf(IllegalStateException::class.java) + .hasMessage("Completed response is not yet received.") + } + + @Test + fun responseAfterAccumulation() { + val accumulator = ResponseAccumulator.create() + + accumulator.accumulate(ResponseStreamEvent.ofCompleted(responseCompletedEvent())) + + assertThatNoException().isThrownBy { accumulator.response() } + assertThat(accumulator.response().id()).isEqualTo("response-id") + } + + @Test + fun structuredResponseAfterAccumulation() { + val accumulator = ResponseAccumulator.create() + + accumulator.accumulate(ResponseStreamEvent.ofCompleted(responseCompletedEvent())) + + // No deserialization is attempted, so the `Class` does not matter. Deserialization is + // beyond the scope of this test; it is tested elsewhere at a lower level. + assertThatNoException().isThrownBy { accumulator.response(String::class.java) } + assertThat(accumulator.response(String::class.java).id()).isEqualTo("response-id") + assertThat(accumulator.response(String::class.java).responseType) + .isEqualTo(String::class.java) + } + + @Test + fun accumulateAfterCompleted() { + val accumulator = ResponseAccumulator.create() + + accumulator.accumulate(ResponseStreamEvent.ofCompleted(responseCompletedEvent())) + + assertThatThrownBy { + accumulator.accumulate(ResponseStreamEvent.ofCompleted(responseCompletedEvent())) + } + .isExactlyInstanceOf(IllegalStateException::class.java) + .hasMessage("Response has already been completed.") + } + + @Test + fun accumulateUntilCompleted() { + val accumulator = ResponseAccumulator.create() + + accumulator.accumulate(ResponseStreamEvent.ofCreated(responseCreatedEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofCompleted(responseCompletedEvent())) + + val response = accumulator.response() + + assertThat(response.id()).isEqualTo("response-id") + } + + @Test + fun accumulateUntilIncomplete() { + val accumulator = ResponseAccumulator.create() + + accumulator.accumulate(ResponseStreamEvent.ofCreated(responseCreatedEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofIncomplete(responseIncompleteEvent())) + + val response = accumulator.response() + + assertThat(response.id()).isEqualTo("response-id") + } + + @Test + fun accumulateUntilFailed() { + val accumulator = ResponseAccumulator.create() + + accumulator.accumulate(ResponseStreamEvent.ofCreated(responseCreatedEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofInProgress(responseInProgressEvent())) + accumulator.accumulate(ResponseStreamEvent.ofFailed(responseFailedEvent())) + + val response = accumulator.response() + + assertThat(response.id()).isEqualTo("response-id") + } + + private fun responseCreatedEvent() = + ResponseCreatedEvent.builder().response(response()).sequenceNumber(1L).build() + + private fun responseInProgressEvent() = + ResponseInProgressEvent.builder().response(response()).sequenceNumber(1L).build() + + private fun responseCompletedEvent() = + ResponseCompletedEvent.builder().response(response()).sequenceNumber(1L).build() + + private fun responseFailedEvent() = + ResponseFailedEvent.builder().response(response()).sequenceNumber(1L).build() + + private fun responseIncompleteEvent() = + ResponseIncompleteEvent.builder().response(response()).sequenceNumber(1L).build() + + private fun response() = + Response.builder() + .id("response-id") + .createdAt(System.currentTimeMillis() / 1_000.0) + .error(null) + .incompleteDetails(null) + .instructions(null) + .metadata(null) + .model(ResponsesModel.ResponsesOnlyModel.O1_PRO) + .addOutput(responseOutputItemOfMessage()) + .parallelToolCalls(false) + .temperature(null) + .toolChoice(JsonNull.of()) + .tools(listOf()) + .topP(null) + .build() + + private fun responseOutputItemOfMessage() = + ResponseOutputItem.ofMessage(responseOutputMessage()) + + private fun responseOutputMessage() = + ResponseOutputMessage.builder() + .id("message-id") + .addContent(ResponseOutputMessage.Content.ofOutputText(responseOutputText())) + .status(ResponseOutputMessage.Status.COMPLETED) + .build() + + private fun responseOutputText() = + ResponseOutputText.builder().text("Hello World").annotations(listOf()).build() +} diff --git a/openai-java-example/src/main/java/com/openai/example/ResponsesStructuredOutputsStreamingExample.java b/openai-java-example/src/main/java/com/openai/example/ResponsesStructuredOutputsStreamingExample.java new file mode 100644 index 000000000..337e23d41 --- /dev/null +++ b/openai-java-example/src/main/java/com/openai/example/ResponsesStructuredOutputsStreamingExample.java @@ -0,0 +1,87 @@ +package com.openai.example; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.core.http.StreamResponse; +import com.openai.helpers.ResponseAccumulator; +import com.openai.models.ChatModel; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseStreamEvent; +import com.openai.models.responses.StructuredResponseCreateParams; +import java.util.List; + +public final class ResponsesStructuredOutputsStreamingExample { + + public static class Person { + @JsonPropertyDescription("The first name and surname of the person.") + public String name; + + public int birthYear; + + @JsonPropertyDescription("The year the person died, or 'present' if the person is living.") + public String deathYear; + + @Override + public String toString() { + return name + " (" + birthYear + '-' + deathYear + ')'; + } + } + + public static class Book { + public String title; + + public Person author; + + @JsonPropertyDescription("The year in which the book was first published.") + public int publicationYear; + + public String genre; + + @JsonIgnore + public String isbn; + + @Override + public String toString() { + return '"' + title + "\" (" + publicationYear + ") [" + genre + "] by " + author; + } + } + + public static class BookList { + public List books; + } + + private ResponsesStructuredOutputsStreamingExample() {} + + public static void main(String[] args) { + // Configures using one of: + // - The `OPENAI_API_KEY` environment variable + // - The `OPENAI_BASE_URL` and `AZURE_OPENAI_KEY` environment variables + OpenAIClient client = OpenAIOkHttpClient.fromEnv(); + + StructuredResponseCreateParams createParams = ResponseCreateParams.builder() + .input("List some famous late twentieth century novels.") + .text(BookList.class) + .model(ChatModel.GPT_4O) + .build(); + + ResponseAccumulator accumulator = ResponseAccumulator.create(); + + try (StreamResponse streamResponse = + client.responses().createStreaming(createParams)) { + streamResponse.stream() + .peek(accumulator::accumulate) + .flatMap(event -> event.outputTextDelta().stream()) + .forEach(textEvent -> System.out.print(textEvent.delta())); + System.out.println(); + } + + accumulator.response(BookList.class).output().stream() + .flatMap(item -> item.message().stream()) + .flatMap(message -> message.content().stream()) + .flatMap(content -> content.outputText().stream()) + .flatMap(bookList -> bookList.books.stream()) + .forEach(book -> System.out.println(" - " + book)); + } +}