diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java index f8f5f801a11..6bb2930fcc8 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatAutoConfiguration.java @@ -77,6 +77,7 @@ public OpenAiApi openAiApi(OpenAiConnectionProperties commonProperties, OpenAiCh .headers(resolved.headers()) .completionsPath(chatProperties.getCompletionsPath()) .embeddingsPath(OpenAiEmbeddingProperties.DEFAULT_EMBEDDINGS_PATH) + .responsesPath(chatProperties.getResponsesPath()) .restClientBuilder(restClientBuilderProvider.getIfAvailable(RestClient::builder)) .webClientBuilder(webClientBuilderProvider.getIfAvailable(WebClient::builder)) .responseErrorHandler(responseErrorHandler) diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java index 44ae7b75332..8ad503f622b 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiChatProperties.java @@ -29,8 +29,12 @@ public class OpenAiChatProperties extends OpenAiParentProperties { public static final String DEFAULT_COMPLETIONS_PATH = "/v1/chat/completions"; + public static final String DEFAULT_RESPONSES_PATH = "/v1/responses"; + private String completionsPath = DEFAULT_COMPLETIONS_PATH; + private String responsesPath = DEFAULT_RESPONSES_PATH; + @NestedConfigurationProperty private final OpenAiChatOptions options = OpenAiChatOptions.builder().model(DEFAULT_CHAT_MODEL).build(); @@ -46,4 +50,12 @@ public void setCompletionsPath(String completionsPath) { this.completionsPath = completionsPath; } + public String getResponsesPath() { + return this.responsesPath; + } + + public void setResponsesPath(String responsesPath) { + this.responsesPath = responsesPath; + } + } diff --git a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingAutoConfiguration.java b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingAutoConfiguration.java index ac85dbdc248..62ed9e46b0a 100644 --- a/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingAutoConfiguration.java +++ b/auto-configurations/models/spring-ai-autoconfigure-model-openai/src/main/java/org/springframework/ai/model/openai/autoconfigure/OpenAiEmbeddingAutoConfiguration.java @@ -92,6 +92,7 @@ private OpenAiApi openAiApi(OpenAiEmbeddingProperties embeddingProperties, .headers(resolved.headers()) .completionsPath(OpenAiChatProperties.DEFAULT_COMPLETIONS_PATH) .embeddingsPath(embeddingProperties.getEmbeddingsPath()) + .responsesPath(OpenAiChatProperties.DEFAULT_RESPONSES_PATH) .restClientBuilder(restClientBuilder) .webClientBuilder(webClientBuilder) .responseErrorHandler(responseErrorHandler) diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java index 92b16f85e49..11fb51e96f6 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/api/OpenAiApi.java @@ -108,6 +108,8 @@ public static Builder builder() { private final String embeddingsPath; + private final String responsesPath; + private final ResponseErrorHandler responseErrorHandler; private final RestClient restClient; @@ -123,22 +125,25 @@ public static Builder builder() { * @param headers the http headers to use. * @param completionsPath the path to the chat completions endpoint. * @param embeddingsPath the path to the embeddings endpoint. + * @param responsesPath the path to the responses endpoint. * @param restClientBuilder RestClient builder. * @param webClientBuilder WebClient builder. * @param responseErrorHandler Response error handler. */ public OpenAiApi(String baseUrl, ApiKey apiKey, HttpHeaders headers, String completionsPath, String embeddingsPath, - RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, + String responsesPath, RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder, ResponseErrorHandler responseErrorHandler) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.headers = headers; this.completionsPath = completionsPath; this.embeddingsPath = embeddingsPath; + this.responsesPath = responsesPath; this.responseErrorHandler = responseErrorHandler; Assert.hasText(completionsPath, "Completions Path must not be null"); Assert.hasText(embeddingsPath, "Embeddings Path must not be null"); + Assert.hasText(responsesPath, "Responses Path must not be null"); Assert.notNull(headers, "Headers must not be null"); // @formatter:off @@ -166,17 +171,20 @@ public OpenAiApi(String baseUrl, ApiKey apiKey, HttpHeaders headers, String comp * @param headers the http headers to use. * @param completionsPath the path to the chat completions endpoint. * @param embeddingsPath the path to the embeddings endpoint. + * @param responsesPath the path to the responses endpoint. * @param restClient RestClient instance. * @param webClient WebClient instance. * @param responseErrorHandler Response error handler. */ public OpenAiApi(String baseUrl, ApiKey apiKey, HttpHeaders headers, String completionsPath, String embeddingsPath, - ResponseErrorHandler responseErrorHandler, RestClient restClient, WebClient webClient) { + String responsesPath, ResponseErrorHandler responseErrorHandler, RestClient restClient, + WebClient webClient) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.headers = headers; this.completionsPath = completionsPath; this.embeddingsPath = embeddingsPath; + this.responsesPath = responsesPath; this.responseErrorHandler = responseErrorHandler; this.restClient = restClient; this.webClient = webClient; @@ -350,6 +358,85 @@ public ResponseEntity> embeddings(EmbeddingRequest< }); } + /** + * Creates a model response for the given request using the Responses API. + * @param responseRequest The response request. + * @return Entity response with {@link Response} as a body and HTTP status code and + * headers. + */ + public ResponseEntity responseEntity(ResponseRequest responseRequest) { + return responseEntity(responseRequest, new HttpHeaders()); + } + + /** + * Creates a model response for the given request using the Responses API. + * @param responseRequest The response request. + * @param additionalHttpHeader Optional, additional HTTP headers to be added to the + * request. + * @return Entity response with {@link Response} as a body and HTTP status code and + * headers. + */ + public ResponseEntity responseEntity(ResponseRequest responseRequest, HttpHeaders additionalHttpHeader) { + + Assert.notNull(responseRequest, REQUEST_BODY_NULL_MESSAGE); + Assert.isTrue(!Boolean.TRUE.equals(responseRequest.stream()), STREAM_FALSE_MESSAGE); + Assert.notNull(additionalHttpHeader, ADDITIONAL_HEADERS_NULL_MESSAGE); + + // @formatter:off + return this.restClient.post() + .uri(this.responsesPath) + .headers(headers -> { + headers.addAll(additionalHttpHeader); + addDefaultHeadersIfMissing(headers); + }) + .body(responseRequest) + .retrieve() + .toEntity(Response.class); + // @formatter:on + } + + /** + * Creates a streaming response for the given request using the Responses API. + * @param responseRequest The response request. Must have the stream property set to + * true. + * @return Returns a {@link Flux} stream from response stream events. + */ + public Flux responseStream(ResponseRequest responseRequest) { + return responseStream(responseRequest, new HttpHeaders()); + } + + /** + * Creates a streaming response for the given request using the Responses API. + * @param responseRequest The response request. Must have the stream property set to + * true. + * @param additionalHttpHeader Optional, additional HTTP headers to be added to the + * request. + * @return Returns a {@link Flux} stream from response stream events. + */ + public Flux responseStream(ResponseRequest responseRequest, HttpHeaders additionalHttpHeader) { + + Assert.notNull(responseRequest, REQUEST_BODY_NULL_MESSAGE); + Assert.isTrue(Boolean.TRUE.equals(responseRequest.stream()), "Request must set the stream property to true."); + Assert.notNull(additionalHttpHeader, ADDITIONAL_HEADERS_NULL_MESSAGE); + + // @formatter:off + return this.webClient.post() + .uri(this.responsesPath) + .headers(headers -> { + headers.addAll(additionalHttpHeader); + addDefaultHeadersIfMissing(headers); + }) + .bodyValue(responseRequest) + .retrieve() + .bodyToFlux(String.class) + // cancels the flux stream after the "[DONE]" is received. + .takeUntil(SSE_DONE_PREDICATE) + // filters out the "[DONE]" message. + .filter(SSE_DONE_PREDICATE.negate()) + .map(content -> ModelOptionsUtils.jsonToObject(content, ResponseStreamEvent.class)); + // @formatter:on + } + private void addDefaultHeadersIfMissing(HttpHeaders headers) { if (headers.get(HttpHeaders.AUTHORIZATION) == null && !(this.apiKey instanceof NoopApiKey)) { headers.setBearerAuth(this.apiKey.getValue()); @@ -377,6 +464,10 @@ String getEmbeddingsPath() { return this.embeddingsPath; } + String getResponsesPath() { + return this.responsesPath; + } + ResponseErrorHandler getResponseErrorHandler() { return this.responseErrorHandler; } @@ -2059,6 +2150,286 @@ public record EmbeddingList(// @formatter:off @JsonProperty("usage") Usage usage) { // @formatter:on } + // Responses API + + /** + * Request to create a model response using the Responses API. + * + * @param model Model ID used to generate the response (e.g., "gpt-4o", "gpt-5"). + * @param input Text, image, or file inputs to the model. Can be a simple string or + * array of input items. + * @param instructions System (developer) message inserted into the model's context. + * @param maxOutputTokens Upper bound for the number of tokens that can be generated. + * @param maxToolCalls Maximum number of total calls to built-in tools. + * @param temperature Sampling temperature to use, between 0 and 2. + * @param topP Nucleus sampling parameter, between 0 and 1. + * @param tools Array of tools the model may call. + * @param toolChoice How the model should select which tool or tools to use. + * @param parallelToolCalls Whether to allow the model to run tool calls in parallel. + * @param stream If set, model response data will be streamed. + * @param store Whether to store the generated model response. + * @param metadata Set of key-value pairs that can be attached to the object. + * @param conversation The conversation that this response belongs to. + * @param previousResponseId The unique ID of the previous response for multi-turn + * conversations. + * @param text Configuration options for text response format. + * @param reasoning Configuration options for reasoning models. + * @param include Specify additional output data to include in the model response. + * @param truncation The truncation strategy to use. + * @param serviceTier Specifies the processing type used for serving the request. + * @param promptCacheKey Used to cache responses for similar requests. + * @param promptCacheRetention The retention policy for the prompt cache. + * @param safetyIdentifier A stable identifier to help detect users violating usage + * policies. + * @param background Whether to run the model response in the background. + */ + @JsonInclude(Include.NON_NULL) + public record ResponseRequest(// @formatter:off + @JsonProperty("model") String model, + @JsonProperty("input") Object input, + @JsonProperty("instructions") String instructions, + @JsonProperty("max_output_tokens") Integer maxOutputTokens, + @JsonProperty("max_tool_calls") Integer maxToolCalls, + @JsonProperty("temperature") Double temperature, + @JsonProperty("top_p") Double topP, + @JsonProperty("tools") List tools, + @JsonProperty("tool_choice") Object toolChoice, + @JsonProperty("parallel_tool_calls") Boolean parallelToolCalls, + @JsonProperty("stream") Boolean stream, + @JsonProperty("store") Boolean store, + @JsonProperty("metadata") Map metadata, + @JsonProperty("conversation") Object conversation, + @JsonProperty("previous_response_id") String previousResponseId, + @JsonProperty("text") TextConfig text, + @JsonProperty("reasoning") ReasoningConfig reasoning, + @JsonProperty("include") List include, + @JsonProperty("truncation") String truncation, + @JsonProperty("service_tier") String serviceTier, + @JsonProperty("prompt_cache_key") String promptCacheKey, + @JsonProperty("prompt_cache_retention") String promptCacheRetention, + @JsonProperty("safety_identifier") String safetyIdentifier, + @JsonProperty("background") Boolean background) { // @formatter:on + + /** + * Shortcut constructor for a response request with the given input and model. + * @param input Text input to the model. + * @param model ID of the model to use. + */ + public ResponseRequest(String input, String model) { + this(model, input, null, null, null, null, null, null, null, null, false, null, null, null, null, null, + null, null, null, null, null, null, null, null); + } + + /** + * Shortcut constructor for a streaming response request. + * @param input Text input to the model. + * @param model ID of the model to use. + * @param stream If set, partial response deltas will be sent. + */ + public ResponseRequest(String input, String model, boolean stream) { + this(model, input, null, null, null, null, null, null, null, null, stream, null, null, null, null, null, + null, null, null, null, null, null, null, null); + } + + /** + * Text configuration for response format. + * + * @param format The format specification for text output. + */ + @JsonInclude(Include.NON_NULL) + public record TextConfig(@JsonProperty("format") TextFormat format) { + } + + /** + * Text format specification. + * + * @param type The type of format (e.g., "text", "json_schema"). + * @param name Schema name (required for json_schema type). + * @param strict Enable strict schema validation. + * @param schema JSON schema object defining output structure. + */ + @JsonInclude(Include.NON_NULL) + public record TextFormat(@JsonProperty("type") String type, @JsonProperty("name") String name, + @JsonProperty("strict") Boolean strict, @JsonProperty("schema") Map schema) { + } + + /** + * Reasoning configuration for reasoning models. + * + * @param effort Reasoning effort level (e.g., "low", "medium", "high"). + * @param generateSummary Whether to generate a summary of reasoning. + * @param summary Summary of reasoning. + */ + @JsonInclude(Include.NON_NULL) + public record ReasoningConfig(@JsonProperty("effort") String effort, + @JsonProperty("generate_summary") Boolean generateSummary, @JsonProperty("summary") String summary) { + } + } + + /** + * Response from the Responses API. + * + * @param id Unique identifier for the response. + * @param object Object type identifier (always "response"). + * @param createdAt Unix timestamp when the response was created. + * @param status Current status of the response. + * @param model Model identifier used to generate the response. + * @param output Array of output items containing generated content. + * @param usage Token usage statistics. + * @param temperature Sampling temperature used. + * @param topP Nucleus sampling parameter used. + * @param toolChoice Tool selection method used. + * @param tools Tools made available to the model. + * @param parallelToolCalls Whether parallel tool execution was enabled. + * @param truncation Truncation strategy applied. + * @param text Text response configuration. + * @param reasoning Reasoning details. + * @param instructions System instructions used. + * @param maxOutputTokens Maximum output tokens limit. + * @param store Whether response is stored. + * @param previousResponseId ID of previous response if continuation. + * @param conversation Conversation context. + * @param metadata Custom metadata. + * @param error Error details if request failed. + * @param incompleteDetails Details about incomplete responses. + * @param serviceTier Service tier used for processing. + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record Response(// @formatter:off + @JsonProperty("id") String id, + @JsonProperty("object") String object, + @JsonProperty("created_at") Long createdAt, + @JsonProperty("status") String status, + @JsonProperty("model") String model, + @JsonProperty("output") List output, + @JsonProperty("usage") Usage usage, + @JsonProperty("temperature") Double temperature, + @JsonProperty("top_p") Double topP, + @JsonProperty("tool_choice") Object toolChoice, + @JsonProperty("tools") List tools, + @JsonProperty("parallel_tool_calls") Boolean parallelToolCalls, + @JsonProperty("truncation") String truncation, + @JsonProperty("text") ResponseRequest.TextConfig text, + @JsonProperty("reasoning") ReasoningDetails reasoning, + @JsonProperty("instructions") String instructions, + @JsonProperty("max_output_tokens") Integer maxOutputTokens, + @JsonProperty("store") Boolean store, + @JsonProperty("previous_response_id") String previousResponseId, + @JsonProperty("conversation") Object conversation, + @JsonProperty("metadata") Map metadata, + @JsonProperty("error") ResponseError error, + @JsonProperty("incomplete_details") IncompleteDetails incompleteDetails, + @JsonProperty("service_tier") String serviceTier) { // @formatter:on + + /** + * Output item from the response. + * + * @param id Unique identifier for the output item. + * @param type Type of the output item (e.g., "message", "reasoning"). + * @param status Status of the output item. + * @param role Role of the message (e.g., "assistant"). + * @param content Array of content items. + * @param summary Summary of reasoning (for reasoning type). + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record OutputItem(// @formatter:off + @JsonProperty("id") String id, + @JsonProperty("type") String type, + @JsonProperty("status") String status, + @JsonProperty("role") String role, + @JsonProperty("content") List content, + @JsonProperty("summary") String summary) { // @formatter:on + } + + /** + * Content item within an output item. + * + * @param type Type of the content (e.g., "output_text"). + * @param text Generated text content. + * @param annotations Content annotations or metadata. + * @param logprobs Log probability information. + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ContentItem(// @formatter:off + @JsonProperty("type") String type, + @JsonProperty("text") String text, + @JsonProperty("annotations") List annotations, + @JsonProperty("logprobs") List logprobs) { // @formatter:on + } + + /** + * Reasoning details in the response. + * + * @param effort Reasoning effort level used. + * @param generateSummary Whether summary generation was requested. + * @param summary Generated summary of reasoning. + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ReasoningDetails(// @formatter:off + @JsonProperty("effort") String effort, + @JsonProperty("generate_summary") Boolean generateSummary, + @JsonProperty("summary") String summary) { // @formatter:on + } + + /** + * Error information if the response failed. + * + * @param code Error code. + * @param message Error message. + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResponseError(// @formatter:off + @JsonProperty("code") String code, + @JsonProperty("message") String message) { // @formatter:on + } + + /** + * Details about incomplete responses. + * + * @param reason Reason why the response is incomplete. + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record IncompleteDetails(// @formatter:off + @JsonProperty("reason") String reason) { // @formatter:on + } + } + + /** + * Stream event from the Responses API. + * + * @param type Type of the streaming event. + * @param sequenceNumber Sequence number for this event. + * @param response Full response object (for response.created, response.completed + * events). + * @param outputIndex Index of the output item. + * @param item Output item that was added or updated. + * @param contentIndex Index of the content part. + * @param itemId ID of the item associated with this event. + * @param delta Text delta for streaming output updates. + * @param text Full text content (for done events). + * @param part Content part that was added or updated. + */ + @JsonInclude(Include.NON_NULL) + @JsonIgnoreProperties(ignoreUnknown = true) + public record ResponseStreamEvent(// @formatter:off + @JsonProperty("type") String type, + @JsonProperty("sequence_number") Integer sequenceNumber, + @JsonProperty("response") Response response, + @JsonProperty("output_index") Integer outputIndex, + @JsonProperty("item") Response.OutputItem item, + @JsonProperty("content_index") Integer contentIndex, + @JsonProperty("item_id") String itemId, + @JsonProperty("delta") String delta, + @JsonProperty("text") String text, + @JsonProperty("part") Response.ContentItem part) { // @formatter:on + } + public static final class Builder { public Builder() { @@ -2072,6 +2443,7 @@ public Builder(OpenAiApi api) { this.headers.addAll(api.getHeaders()); this.completionsPath = api.getCompletionsPath(); this.embeddingsPath = api.getEmbeddingsPath(); + this.responsesPath = api.getResponsesPath(); this.restClientBuilder = api.restClient != null ? api.restClient.mutate() : RestClient.builder(); this.webClientBuilder = api.webClient != null ? api.webClient.mutate() : WebClient.builder(); this.responseErrorHandler = api.getResponseErrorHandler(); @@ -2087,6 +2459,8 @@ public Builder(OpenAiApi api) { private String embeddingsPath = "/v1/embeddings"; + private String responsesPath = "/v1/responses"; + private RestClient.Builder restClientBuilder = RestClient.builder(); private WebClient.Builder webClientBuilder = WebClient.builder(); @@ -2128,6 +2502,12 @@ public Builder embeddingsPath(String embeddingsPath) { return this; } + public Builder responsesPath(String responsesPath) { + Assert.hasText(responsesPath, "responsesPath cannot be null or empty"); + this.responsesPath = responsesPath; + return this; + } + public Builder restClientBuilder(RestClient.Builder restClientBuilder) { Assert.notNull(restClientBuilder, "restClientBuilder cannot be null"); this.restClientBuilder = restClientBuilder; @@ -2149,7 +2529,7 @@ public Builder responseErrorHandler(ResponseErrorHandler responseErrorHandler) { public OpenAiApi build() { Assert.notNull(this.apiKey, "apiKey must be set"); return new OpenAiApi(this.baseUrl, this.apiKey, this.headers, this.completionsPath, this.embeddingsPath, - this.restClientBuilder, this.webClientBuilder, this.responseErrorHandler); + this.responsesPath, this.restClientBuilder, this.webClientBuilder, this.responseErrorHandler); } } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java index 1aa8476d166..f82a1fee99d 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/OpenAiApiIT.java @@ -295,4 +295,178 @@ void userAgentHeaderIsSentInChatCompletionRequests() throws Exception { } } + // Responses API Tests + + @Test + void responseEntity() { + // Create a simple response request + OpenAiApi.ResponseRequest request = new OpenAiApi.ResponseRequest("Say hello in one sentence", "gpt-4o"); + + ResponseEntity response = this.openAiApi.responseEntity(request); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().id()).isNotNull(); + assertThat(response.getBody().object()).isEqualTo("response"); + assertThat(response.getBody().status()).isEqualTo("completed"); + assertThat(response.getBody().model()).contains("gpt-4o"); + assertThat(response.getBody().output()).isNotEmpty(); + + // Verify output contains a message with text content + OpenAiApi.Response.OutputItem firstOutput = response.getBody().output().get(0); + assertThat(firstOutput).isNotNull(); + assertThat(firstOutput.content()).isNotEmpty(); + + // Find and verify text content + boolean hasTextContent = firstOutput.content() + .stream() + .anyMatch(content -> "output_text".equals(content.type()) && content.text() != null + && !content.text().isEmpty()); + assertThat(hasTextContent).isTrue(); + + // Verify usage information + assertThat(response.getBody().usage()).isNotNull(); + assertThat(response.getBody().usage().totalTokens()).isPositive(); + } + + @Test + void responseStream() { + // Create a streaming response request + OpenAiApi.ResponseRequest request = new OpenAiApi.ResponseRequest("Count from 1 to 3", "gpt-4o", true); + + Flux eventStream = this.openAiApi.responseStream(request); + + assertThat(eventStream).isNotNull(); + + List events = eventStream.collectList().block(); + + assertThat(events).isNotNull(); + assertThat(events).isNotEmpty(); + + // Verify we received the expected event types + boolean hasCreatedEvent = events.stream().anyMatch(e -> "response.created".equals(e.type())); + boolean hasOutputEvent = events.stream() + .anyMatch(e -> e.type() != null && e.type().contains("output") || e.type().contains("delta")); + boolean hasCompletedEvent = events.stream() + .anyMatch(e -> e.type() != null && e.type().contains("completed") || e.type().contains("done")); + + assertThat(hasCreatedEvent || hasOutputEvent || hasCompletedEvent).isTrue(); + + // Verify at least some events have sequence numbers + boolean hasSequenceNumbers = events.stream().anyMatch(e -> e.sequenceNumber() != null); + assertThat(hasSequenceNumbers).isTrue(); + } + + @Test + void responseWithInstructionsAndConfiguration() { + // Create a request with custom configuration + OpenAiApi.ResponseRequest request = new OpenAiApi.ResponseRequest("gpt-4o", // model + "What is 2+2?", // input + "You are a helpful math tutor", // instructions + 100, // maxOutputTokens + null, // maxToolCalls + 0.7, // temperature + null, // topP + null, // tools + null, // toolChoice + null, // parallelToolCalls + false, // stream + true, // store + null, // metadata + null, // conversation + null, // previousResponseId + null, // text + null, // reasoning + null, // include + null, // truncation + null, // serviceTier + null, // promptCacheKey + null, // promptCacheRetention + null, // safetyIdentifier + null // background + ); + + ResponseEntity response = this.openAiApi.responseEntity(request); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().status()).isEqualTo("completed"); + assertThat(response.getBody().temperature()).isEqualTo(0.7); + assertThat(response.getBody().store()).isTrue(); + + // Verify the response contains an answer + String outputText = response.getBody() + .output() + .stream() + .filter(item -> "message".equals(item.type())) + .flatMap(item -> item.content().stream()) + .filter(content -> "output_text".equals(content.type())) + .map(OpenAiApi.Response.ContentItem::text) + .findFirst() + .orElse(null); + + assertThat(outputText).isNotNull(); + assertThat(outputText).containsAnyOf("4", "four"); + } + + @Test + void responseWithWebSearchTool() { + // Create a web_search tool configuration + // The web_search tool allows the model to search the internet for current + // information + var webSearchTool = java.util.Map.of("type", "web_search"); + + // Create a request that requires current information from the web + OpenAiApi.ResponseRequest request = new OpenAiApi.ResponseRequest("gpt-4o", // model + "What is the current weather in San Francisco?", // input - requires web + // search + null, // instructions + null, // maxOutputTokens + null, // maxToolCalls + null, // temperature + null, // topP + List.of(webSearchTool), // tools - enable web_search + null, // toolChoice + null, // parallelToolCalls + false, // stream + null, // store + null, // metadata + null, // conversation + null, // previousResponseId + null, // text + null, // reasoning + List.of("web_search_call.action.sources"), // include - get search sources + null, // truncation + null, // serviceTier + null, // promptCacheKey + null, // promptCacheRetention + null, // safetyIdentifier + null // background + ); + + ResponseEntity response = this.openAiApi.responseEntity(request); + + assertThat(response).isNotNull(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().status()).isEqualTo("completed"); + assertThat(response.getBody().output()).isNotEmpty(); + + // Verify that web_search tool was called + boolean hasWebSearchCall = response.getBody() + .output() + .stream() + .anyMatch(item -> "web_search_call".equals(item.type())); + + assertThat(hasWebSearchCall).as("Response should contain a web_search_call output item").isTrue(); + + // Verify the final response contains information (likely from web search) + boolean hasMessageOutput = response.getBody().output().stream().anyMatch(item -> "message".equals(item.type())); + + assertThat(hasMessageOutput).as("Response should contain a message with the answer").isTrue(); + + // Verify usage information includes the web search + assertThat(response.getBody().usage()).isNotNull(); + assertThat(response.getBody().usage().totalTokens()).isPositive(); + } + } diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/ResponsesApiTest.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/ResponsesApiTest.java new file mode 100644 index 00000000000..94befab798e --- /dev/null +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/api/ResponsesApiTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.openai.api; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.openai.api.OpenAiApi.Response; +import org.springframework.ai.openai.api.OpenAiApi.ResponseRequest; +import org.springframework.ai.openai.api.OpenAiApi.ResponseStreamEvent; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for the Responses API DTOs and methods. + * + * @author Alexandros Pappas + */ +class ResponsesApiTest { + + @Test + void testResponseRequestCreation() { + ResponseRequest request = new ResponseRequest("Test input", "gpt-4o"); + + assertThat(request).isNotNull(); + assertThat(request.input()).isEqualTo("Test input"); + assertThat(request.model()).isEqualTo("gpt-4o"); + assertThat(request.stream()).isFalse(); + } + + @Test + void testResponseRequestCreationWithStream() { + ResponseRequest request = new ResponseRequest("Test input", "gpt-4o", true); + + assertThat(request).isNotNull(); + assertThat(request.input()).isEqualTo("Test input"); + assertThat(request.model()).isEqualTo("gpt-4o"); + assertThat(request.stream()).isTrue(); + } + + @Test + void testResponseRequestWithAllParameters() { + ResponseRequest request = new ResponseRequest("gpt-4o", "Test input", "You are a helpful assistant", 1000, null, + 0.7, null, null, null, null, false, true, null, null, null, null, null, null, null, null, null, null, + null, null); + + assertThat(request).isNotNull(); + assertThat(request.model()).isEqualTo("gpt-4o"); + assertThat(request.input()).isEqualTo("Test input"); + assertThat(request.instructions()).isEqualTo("You are a helpful assistant"); + assertThat(request.maxOutputTokens()).isEqualTo(1000); + assertThat(request.temperature()).isEqualTo(0.7); + assertThat(request.stream()).isFalse(); + assertThat(request.store()).isTrue(); + } + + @Test + void testResponseStructure() { + Response response = new Response("resp_123", "response", 1234567890L, "completed", "gpt-4o", null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo("resp_123"); + assertThat(response.object()).isEqualTo("response"); + assertThat(response.status()).isEqualTo("completed"); + assertThat(response.model()).isEqualTo("gpt-4o"); + } + + @Test + void testResponseStreamEventStructure() { + ResponseStreamEvent event = new ResponseStreamEvent("response.created", 1, null, null, null, null, null, null, + null, null); + + assertThat(event).isNotNull(); + assertThat(event.type()).isEqualTo("response.created"); + assertThat(event.sequenceNumber()).isEqualTo(1); + } + +} diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc index 4b0ba8de2b2..b6a75652324 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc @@ -150,7 +150,8 @@ The prefix `spring.ai.openai.chat` is the property prefix that lets you configur | spring.ai.openai.chat.enabled (Removed and no longer valid) | Enable OpenAI chat model. | true | spring.ai.model.chat | Enable OpenAI chat model. | openai | spring.ai.openai.chat.base-url | Optional override for the `spring.ai.openai.base-url` property to provide a chat-specific URL. | - -| spring.ai.openai.chat.completions-path | The path to append to the base URL. | `/v1/chat/completions` +| spring.ai.openai.chat.completions-path | The path to append to the base URL for Chat Completions API. | `/v1/chat/completions` +| spring.ai.openai.chat.responses-path | The path to append to the base URL for Responses API. | `/v1/responses` | spring.ai.openai.chat.api-key | Optional override for the `spring.ai.openai.api-key` to provide a chat-specific API Key. | - | spring.ai.openai.chat.organization-id | Optionally, you can specify which organization to use for an API request. | - | spring.ai.openai.chat.project-id | Optionally, you can specify which project to use for an API request. | - @@ -884,7 +885,7 @@ Spring AI maps this field from the JSON response to the `reasoningContent` key i Official OpenAI reasoning models hide the chain-of-thought content when using the Chat Completions API. They only expose `reasoning_tokens` count in usage statistics. -To access actual reasoning text from official OpenAI models, you must use OpenAI's Responses API (a separate endpoint not currently supported by this client). +To access actual reasoning text from official OpenAI models, you must use OpenAI's Responses API (available via the low-level `OpenAiApi` class). **Fallback behavior:** When `reasoning_content` is not provided by the server (e.g., official OpenAI Chat Completions), the `reasoningContent` metadata field will be an empty string. ====