From 08732ce910e714f4f77b55dc2d6d1d0be3cf2a52 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sun, 16 Nov 2025 12:50:11 +0100 Subject: [PATCH 1/6] feat: Add native structured output support for ChatClient Implement StructuredOutputChatOptions interface to provide unified structured output support across AI providers. This enables AI models that provide built-in structured output to natively generate JSON responses that conform to a specified schema without additional prompt engineering. Models that provide structured response should implement the StructuredOutputChatOptions. To activate the native over the ChatClient prompt-based structured output response**,** you need to add the AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT advisor parameter to your ChatClient configuration. - Add StructuredOutputChatOptions interface with getOutputSchema/setOutputSchema methods - Implement interface in AnthropicChatOptions, OpenAiChatOptions, and VertexAiGeminiChatOptions - Update AnthropicApi to support output_format parameter and add structured-outputs-2025-11-13 beta version - Add ChatClientAttributes for STRUCTURED_OUTPUT_SCHEMA and STRUCTURED_OUTPUT_NATIVE - Enhance ChatModelCallAdvisor to set output schema when native structured output is enabled - Update DefaultChatClient to handle native structured output via context attributes - Configure BeanOutputConverter to mark all fields as required in generated JSON schemas - Add AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT for easy activation via ChatClient - Add integration tests for native structured output across all three providers Fixes #4889 Addresses #4463 Part of #2787 Signed-off-by: Christian Tzolov --- .../ai/anthropic/AnthropicChatOptions.java | 45 ++++- .../ai/anthropic/api/AnthropicApi.java | 29 ++- .../client/AnthropicChatClientIT.java | 20 +++ .../ai/openai/OpenAiChatOptions.java | 21 ++- .../chat/client/OpenAiChatClientIT.java | 33 +++- .../gemini/VertexAiGeminiChatOptions.java | 29 ++- .../gemini/VertexAiGeminiChatModelIT.java | 60 ++++++- .../ai/chat/client/AdvisorParams.java | 38 ++++ .../ai/chat/client/ChatClientAttributes.java | 6 +- .../ai/chat/client/DefaultChatClient.java | 37 ++-- .../client/advisor/ChatModelCallAdvisor.java | 15 ++ ...atClientNativeStructuredResponseTests.java | 166 ++++++++++++++++++ .../ai/converter/BeanOutputConverter.java | 2 + .../tool/StructuredOutputChatOptions.java | 33 ++++ 14 files changed, 509 insertions(+), 25 deletions(-) create mode 100644 spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java create mode 100644 spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientNativeStructuredResponseTests.java create mode 100644 spring-ai-model/src/main/java/org/springframework/ai/model/tool/StructuredOutputChatOptions.java diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java index 8061bb71e42..c7e7758850d 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java @@ -32,8 +32,11 @@ import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest; +import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest.OutputFormat; import org.springframework.ai.anthropic.api.AnthropicCacheOptions; import org.springframework.ai.anthropic.api.CitationDocument; +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; import org.springframework.lang.Nullable; @@ -51,7 +54,7 @@ * @since 1.0.0 */ @JsonInclude(Include.NON_NULL) -public class AnthropicChatOptions implements ToolCallingChatOptions { +public class AnthropicChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions { // @formatter:off private @JsonProperty("model") String model; @@ -115,6 +118,11 @@ public void setCacheOptions(AnthropicCacheOptions cacheOptions) { @JsonIgnore private Map httpHeaders = new HashMap<>(); + /** + * The desired response format for structured output. + */ + private @JsonProperty("output_format") OutputFormat outputFormat; + // @formatter:on public static Builder builder() { @@ -141,6 +149,7 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions) .cacheOptions(fromOptions.getCacheOptions()) .citationDocuments(fromOptions.getCitationDocuments() != null ? new ArrayList<>(fromOptions.getCitationDocuments()) : null) + .outputFormat(fromOptions.getOutputFormat()) .build(); } @@ -325,6 +334,27 @@ public void validateCitationConsistency() { } } + public OutputFormat getOutputFormat() { + return this.outputFormat; + } + + public void setOutputFormat(OutputFormat outputFormat) { + Assert.notNull(outputFormat, "outputFormat cannot be null"); + this.outputFormat = outputFormat; + } + + @Override + @JsonIgnore + public String getOutputSchema() { + return this.getOutputFormat() != null ? ModelOptionsUtils.toJsonString(this.getOutputFormat().schema()) : null; + } + + @Override + @JsonIgnore + public void setOutputSchema(String outputSchema) { + this.setOutputFormat(new OutputFormat(outputSchema)); + } + @Override @SuppressWarnings("unchecked") public AnthropicChatOptions copy() { @@ -351,6 +381,7 @@ public boolean equals(Object o) { && Objects.equals(this.toolContext, that.toolContext) && Objects.equals(this.httpHeaders, that.httpHeaders) && Objects.equals(this.cacheOptions, that.cacheOptions) + && Objects.equals(this.outputFormat, that.outputFormat) && Objects.equals(this.citationDocuments, that.citationDocuments); } @@ -359,7 +390,7 @@ public int hashCode() { return Objects.hash(this.model, this.maxTokens, this.metadata, this.stopSequences, this.temperature, this.topP, this.topK, this.toolChoice, this.thinking, this.toolCallbacks, this.toolNames, this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions, - this.citationDocuments); + this.outputFormat, this.citationDocuments); } public static final class Builder { @@ -501,6 +532,16 @@ public Builder addCitationDocument(CitationDocument document) { return this; } + public Builder outputFormat(OutputFormat outputFormat) { + this.options.outputFormat = outputFormat; + return this; + } + + public Builder outputSchema(String outputSchema) { + this.options.setOutputSchema(outputSchema); + return this; + } + public AnthropicChatOptions build() { this.options.validateCitationConsistency(); return this.options; diff --git a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java index e18c4d38801..6ab9d65c2c2 100644 --- a/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java +++ b/models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java @@ -84,7 +84,7 @@ public static Builder builder() { public static final String DEFAULT_ANTHROPIC_VERSION = "2023-06-01"; - public static final String DEFAULT_ANTHROPIC_BETA_VERSION = "tools-2024-04-04,pdfs-2024-09-25"; + public static final String DEFAULT_ANTHROPIC_BETA_VERSION = "tools-2024-04-04,pdfs-2024-09-25,structured-outputs-2025-11-13"; public static final String BETA_EXTENDED_CACHE_TTL = "extended-cache-ttl-2025-04-11"; @@ -542,18 +542,20 @@ public record ChatCompletionRequest( @JsonProperty("top_k") Integer topK, @JsonProperty("tools") List tools, @JsonProperty("tool_choice") ToolChoice toolChoice, - @JsonProperty("thinking") ThinkingConfig thinking) { + @JsonProperty("thinking") ThinkingConfig thinking, + @JsonProperty("output_format") OutputFormat outputFormat) { // @formatter:on public ChatCompletionRequest(String model, List messages, Object system, Integer maxTokens, Double temperature, Boolean stream) { - this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null); + this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null, + null); } public ChatCompletionRequest(String model, List messages, Object system, Integer maxTokens, List stopSequences, Double temperature, Boolean stream) { this(model, messages, system, maxTokens, null, stopSequences, stream, temperature, null, null, null, null, - null); + null, null); } public static ChatCompletionRequestBuilder builder() { @@ -564,6 +566,15 @@ public static ChatCompletionRequestBuilder from(ChatCompletionRequest request) { return new ChatCompletionRequestBuilder(request); } + @JsonInclude(Include.NON_NULL) + public record OutputFormat(@JsonProperty("type") String type, + @JsonProperty("schema") Map schema) { + + public OutputFormat(String jsonSchema) { + this("json_schema", ModelOptionsUtils.jsonToMap(jsonSchema)); + } + } + /** * Metadata about the request. * @@ -631,6 +642,8 @@ public static final class ChatCompletionRequestBuilder { private ChatCompletionRequest.ThinkingConfig thinking; + private ChatCompletionRequest.OutputFormat outputFormat; + private ChatCompletionRequestBuilder() { } @@ -648,6 +661,7 @@ private ChatCompletionRequestBuilder(ChatCompletionRequest request) { this.tools = request.tools; this.toolChoice = request.toolChoice; this.thinking = request.thinking; + this.outputFormat = request.outputFormat; } public ChatCompletionRequestBuilder model(ChatModel model) { @@ -725,10 +739,15 @@ public ChatCompletionRequestBuilder thinking(ThinkingType type, Integer budgetTo return this; } + public ChatCompletionRequestBuilder outputFormat(ChatCompletionRequest.OutputFormat outputFormat) { + this.outputFormat = outputFormat; + return this; + } + public ChatCompletionRequest build() { return new ChatCompletionRequest(this.model, this.messages, this.system, this.maxTokens, this.metadata, this.stopSequences, this.stream, this.temperature, this.topP, this.topK, this.tools, - this.toolChoice, this.thinking); + this.toolChoice, this.thinking, this.outputFormat); } } diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java index 160059ee282..1c859048c0f 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java @@ -35,6 +35,7 @@ import org.springframework.ai.anthropic.AnthropicTestConfiguration; import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.anthropic.api.tool.MockWeatherService; +import org.springframework.ai.chat.client.AdvisorParams; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.model.ChatModel; @@ -118,6 +119,25 @@ void listOutputConverterBean() { assertThat(actorsFilms).hasSize(2); } + @Test + void listOutputConverterBean2() { + + // @formatter:off + List actorsFilms = ChatClient.create(this.chatModel).prompt() + .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .options(AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) + .build()) + .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms).hasSize(2); + } + @Test void customOutputConverter() { diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java index 703134b509c..b545a0623d8 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java @@ -33,6 +33,7 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.AudioParameters; @@ -40,6 +41,7 @@ import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ToolChoiceBuilder; import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.WebSearchOptions; import org.springframework.ai.openai.api.ResponseFormat; +import org.springframework.ai.openai.api.ResponseFormat.Type; import org.springframework.ai.tool.ToolCallback; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -55,7 +57,7 @@ * @since 0.8.0 */ @JsonInclude(Include.NON_NULL) -public class OpenAiChatOptions implements ToolCallingChatOptions { +public class OpenAiChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions { private static final Logger logger = LoggerFactory.getLogger(OpenAiChatOptions.class); @@ -675,6 +677,18 @@ public void setSafetyIdentifier(String safetyIdentifier) { this.safetyIdentifier = safetyIdentifier; } + @Override + @JsonIgnore + public String getOutputSchema() { + return this.getResponseFormat().getSchema(); + } + + @Override + @JsonIgnore + public void setOutputSchema(String outputSchema) { + this.setResponseFormat(ResponseFormat.builder().type(Type.JSON_SCHEMA).jsonSchema(outputSchema).build()); + } + @Override public OpenAiChatOptions copy() { return OpenAiChatOptions.fromOptions(this); @@ -871,6 +885,11 @@ public Builder responseFormat(ResponseFormat responseFormat) { return this; } + public Builder outputSchema(String outputSchema) { + this.options.setOutputSchema(outputSchema); + return this; + } + public Builder streamUsage(boolean enableStreamUsage) { this.options.streamOptions = (enableStreamUsage) ? StreamOptions.INCLUDE_USAGE : null; return this; diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java index 708cadd1178..19896b87164 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java @@ -32,6 +32,7 @@ import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; +import org.springframework.ai.chat.client.AdvisorParams; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.model.ChatResponse; @@ -97,7 +98,6 @@ void re2() { logger.info("" + response); assertThat(response.toLowerCase().replace("(", " ").replace(")", " ").replace("\"", " ").replace("\"", " ")) .contains(" eight", " one", " ten", " nine"); - } @Test @@ -195,6 +195,21 @@ void beanOutputConverter() { assertThat(actorsFilms.actor()).isNotBlank(); } + @Test + void beanOutputConverterNativeStructuredOutput() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() + .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .user("Generate the filmography for a random actor.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isNotBlank(); + } + @Test void beanOutputConverterRecords() { @@ -210,6 +225,22 @@ void beanOutputConverterRecords() { assertThat(actorsFilms.movies()).hasSize(5); } + @Test + void beanOutputConverterRecordsNativeStructuredOutput() { + + // @formatter:off + ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() + .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .user("Generate the filmography of 5 movies for Tom Hanks.") + .call() + .entity(ActorsFilms.class); + // @formatter:on + + logger.info("" + actorsFilms); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + @Test void beanStreamOutputConverterRecords() { diff --git a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatOptions.java b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatOptions.java index c489af028fa..d8b96c13d00 100644 --- a/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatOptions.java +++ b/models/spring-ai-vertex-ai-gemini/src/main/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatOptions.java @@ -29,11 +29,15 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel.ChatModel; import org.springframework.ai.vertexai.gemini.common.VertexAiGeminiSafetySetting; +import org.springframework.ai.vertexai.gemini.schema.JsonSchemaConverter; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -48,7 +52,7 @@ * @since 1.0.0 */ @JsonInclude(Include.NON_NULL) -public class VertexAiGeminiChatOptions implements ToolCallingChatOptions { +public class VertexAiGeminiChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions { // https://cloud.google.com/vertex-ai/docs/reference/rest/v1/GenerationConfig @@ -119,7 +123,7 @@ public class VertexAiGeminiChatOptions implements ToolCallingChatOptions { private @JsonProperty("responseMimeType") String responseMimeType; /** - * Optional. OpenAPI response schema. + * Optional. Geminie response schema. */ private @JsonProperty("responseSchema") String responseSchema; @@ -388,6 +392,22 @@ public boolean getResponseLogprobs() { return this.responseLogprobs; } + @Override + public String getOutputSchema() { + return this.getResponseSchema(); + } + + @Override + @JsonIgnore + public void setOutputSchema(String jsonSchemaText) { + ObjectNode jsonSchema = JsonSchemaConverter.fromJson(jsonSchemaText); + ObjectNode openApiSchema = JsonSchemaConverter.convertToOpenApiSchema(jsonSchema); + JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema); + + this.setResponseSchema(openApiSchema.toPrettyString()); + this.setResponseMimeType("application/json"); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -515,6 +535,11 @@ public Builder responseSchema(String responseSchema) { return this; } + public Builder outputSchema(String outputSchema) { + this.options.setOutputSchema(outputSchema); + return this; + } + public Builder toolCallbacks(List toolCallbacks) { this.options.toolCallbacks = toolCallbacks; return this; diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java index cbbc74f1517..81231e08331 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java @@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.ai.chat.client.AdvisorParams; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; @@ -53,6 +54,7 @@ import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; @@ -209,6 +211,62 @@ void beanOutputConverterRecords() { assertThat(actorsFilms.movies()).hasSize(5); } + @Test + void chatClientBeanOutputConverterRecords() { + + var chatClient = ChatClient.builder(this.chatModel).build(); + + ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.") + .call() + .entity(ActorsFilmsRecord.class); + + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void listOutputConverterBean() { + + // @formatter:off + List actorsFilms = ChatClient.create(this.chatModel).prompt() + .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + assertThat(actorsFilms).hasSize(2); + } + + @Test + void listOutputConverterBean2() { + + // @formatter:off + List actorsFilms = ChatClient.create(this.chatModel).prompt() + .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + assertThat(actorsFilms).hasSize(2); + } + + @Test + void chatClientBeanOutputConverterRecords2() { + + var chatClient = ChatClient.builder(this.chatModel).build(); + + ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.") + .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .call() + .entity(ActorsFilmsRecord.class); + + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + @Test void textStream() { @@ -436,7 +494,7 @@ void testMixedPartsMessages() { .call() .content(); - assertThat(response).contains("set an alarm for 11:10 AM."); + assertThat(response).contains("I have set an alarm for 11:10 AM."); assertThat(alarmTools.getAlarm()).isEqualTo("2025-05-08T11:10:10+02:00"); } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java new file mode 100644 index 00000000000..9c417369b41 --- /dev/null +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java @@ -0,0 +1,38 @@ +/* + * 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.chat.client; + +import java.util.function.Consumer; + +/** + * Configuration options for the ChatClient request. + * + * Preset advisors parameters that can be passed as configuration options to the Advisor + * context. + * + * @author Christian Tzolov + */ + +public final class AdvisorParams { + + private AdvisorParams() { + } + + public static final Consumer WITH_NATIVE_STRUCTURED_OUTPUT = a -> a + .param(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey(), true); + +} diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java index c0c6fe22525..464ade901b8 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClientAttributes.java @@ -26,7 +26,11 @@ public enum ChatClientAttributes { //@formatter:off - OUTPUT_FORMAT("spring.ai.chat.client.output.format"); + OUTPUT_FORMAT("spring.ai.chat.client.output.format"), + + STRUCTURED_OUTPUT_SCHEMA("spring.ai.chat.client.structured.output.schema"), + + STRUCTURED_OUTPUT_NATIVE("spring.ai.chat.client.structured.output.native"); //@formatter:on diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index 81d6a43ac43..7b2f082704b 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -433,8 +433,16 @@ public ResponseEntity responseEntity( protected ResponseEntity doResponseEntity(StructuredOutputConverter outputConverter) { Assert.notNull(outputConverter, "structuredOutputConverter cannot be null"); - var chatResponse = doGetObservableChatClientResponse(this.request, outputConverter.getFormat()) - .chatResponse(); + + this.request.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputConverter.getFormat()); + + if (this.request.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()) + && outputConverter instanceof BeanOutputConverter beanOutputConverter) { + this.request.context() + .put(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), beanOutputConverter.getJsonSchema()); + } + + var chatResponse = doGetObservableChatClientResponse(this.request).chatResponse(); var responseContent = getContentFromChatResponse(chatResponse); if (responseContent == null) { return new ResponseEntity<>(chatResponse, null); @@ -467,8 +475,19 @@ public T entity(Class type) { @Nullable private T doSingleWithBeanOutputConverter(StructuredOutputConverter outputConverter) { - var chatResponse = doGetObservableChatClientResponse(this.request, outputConverter.getFormat()) - .chatResponse(); + if (outputConverter != null && StringUtils.hasText(outputConverter.getFormat())) { + this.request.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputConverter.getFormat()); + + if (this.request.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()) + && outputConverter instanceof BeanOutputConverter beanOutputConverter) { + this.request.context() + .put(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), + beanOutputConverter.getJsonSchema()); + } + } + + var chatResponse = doGetObservableChatClientResponse(this.request).chatResponse(); + var stringResponse = getContentFromChatResponse(chatResponse); if (stringResponse == null) { return null; @@ -495,15 +514,9 @@ public String content() { } private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest chatClientRequest) { - return doGetObservableChatClientResponse(chatClientRequest, null); - } - private ChatClientResponse doGetObservableChatClientResponse(ChatClientRequest chatClientRequest, - @Nullable String outputFormat) { - - if (outputFormat != null) { - chatClientRequest.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputFormat); - } + String outputFormat = (String) chatClientRequest.context() + .getOrDefault(ChatClientAttributes.OUTPUT_FORMAT.getKey(), null); ChatClientObservationContext observationContext = ChatClientObservationContext.builder() .request(chatClientRequest) diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java index 051cbdd808c..bcdfa8e21bd 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java @@ -26,6 +26,7 @@ import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; import org.springframework.core.Ordered; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -34,6 +35,7 @@ * A {@link CallAdvisor} that uses a {@link ChatModel} to generate a response. * * @author Thomas Vitale + * @author Christian Tzolov * @since 1.0.0 */ public final class ChatModelCallAdvisor implements CallAdvisor { @@ -52,6 +54,7 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd ChatClientRequest formattedChatClientRequest = augmentWithFormatInstructions(chatClientRequest); ChatResponse chatResponse = this.chatModel.call(formattedChatClientRequest.prompt()); + return ChatClientResponse.builder() .chatResponse(chatResponse) .context(Map.copyOf(formattedChatClientRequest.context())) @@ -65,6 +68,18 @@ private static ChatClientRequest augmentWithFormatInstructions(ChatClientRequest return chatClientRequest; } + if (chatClientRequest.prompt().getOptions() instanceof StructuredOutputChatOptions structuredOutputChatOptions + && chatClientRequest.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey())) { + + String outputSchema = (String) chatClientRequest.context() + .get(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey()); + + if (StringUtils.hasText(outputSchema)) { + structuredOutputChatOptions.setOutputSchema(outputSchema); + return chatClientRequest; + } + } + Prompt augmentedPrompt = chatClientRequest.prompt() .augmentUserMessage(userMessage -> userMessage.mutate() .text(userMessage.getText() + System.lineSeparator() + outputFormat) diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientNativeStructuredResponseTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientNativeStructuredResponseTests.java new file mode 100644 index 00000000000..0d254cc6be7 --- /dev/null +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientNativeStructuredResponseTests.java @@ -0,0 +1,166 @@ +/* + * Copyright 2025-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.chat.client; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.ai.chat.client.advisor.api.CallAdvisor; +import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * @author Christian Tzolov + */ +@ExtendWith(MockitoExtension.class) +public class ChatClientNativeStructuredResponseTests { + + @Mock + ChatModel chatModel; + + @Mock + StructuredOutputChatOptions structuredOutputChatOptions; + + @Captor + ArgumentCaptor promptCaptor; + + @Test + public void fallBackEntityTest() { + + ChatResponseMetadata metadata = ChatResponseMetadata.builder().keyValue("key1", "value1").build(); + + var chatResponse = new ChatResponse(List.of(new Generation(new AssistantMessage(""" + {"name":"John", "age":30} + """))), metadata); + + given(this.chatModel.call(this.promptCaptor.capture())).willReturn(chatResponse); + + var textCallAdvisor = new ContextCatcherCallAdvisor(); + ResponseEntity responseEntity = ChatClient.builder(this.chatModel) + .build() + .prompt() + .options(this.structuredOutputChatOptions) + .advisors(textCallAdvisor) + .user("Tell me about John") + .call() + .responseEntity(UserEntity.class); + + var context = textCallAdvisor.getContext(); + + assertThat(context).containsKey(ChatClientAttributes.OUTPUT_FORMAT.getKey()); + assertThat(context).doesNotContainKey(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey()); + assertThat(context).doesNotContainKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()); + + assertThat(responseEntity.getResponse()).isEqualTo(chatResponse); + assertThat(responseEntity.getResponse().getMetadata().get("key1").toString()).isEqualTo("value1"); + + assertThat(responseEntity.getEntity()).isEqualTo(new UserEntity("John", 30)); + + Message userMessage = this.promptCaptor.getValue().getInstructions().get(0); + assertThat(userMessage.getMessageType()).isEqualTo(MessageType.USER); + assertThat(userMessage.getText()).contains("Tell me about John"); + } + + @Test + public void nativeEntityTest() { + + ChatResponseMetadata metadata = ChatResponseMetadata.builder().keyValue("key1", "value1").build(); + + var chatResponse = new ChatResponse(List.of(new Generation(new AssistantMessage(""" + {"name":"John", "age":30} + """))), metadata); + + given(this.chatModel.call(this.promptCaptor.capture())).willReturn(chatResponse); + + var textCallAdvisor = new ContextCatcherCallAdvisor(); + + ResponseEntity responseEntity = ChatClient.builder(this.chatModel) + .build() + .prompt() + .options(this.structuredOutputChatOptions) + .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(textCallAdvisor) + .user("Tell me about John") + .call() + .responseEntity(UserEntity.class); + + var context = textCallAdvisor.getContext(); + + assertThat(context).containsKey(ChatClientAttributes.OUTPUT_FORMAT.getKey()); + assertThat(context).containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey()); + assertThat(context).containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()); + + assertThat(responseEntity.getResponse()).isEqualTo(chatResponse); + assertThat(responseEntity.getResponse().getMetadata().get("key1").toString()).isEqualTo("value1"); + + assertThat(responseEntity.getEntity()).isEqualTo(new UserEntity("John", 30)); + + Message userMessage = this.promptCaptor.getValue().getInstructions().get(0); + assertThat(userMessage.getMessageType()).isEqualTo(MessageType.USER); + assertThat(userMessage.getText()).contains("Tell me about John"); + } + + record UserEntity(String name, int age) { + } + + private static class ContextCatcherCallAdvisor implements CallAdvisor { + + private Map context = new ConcurrentHashMap<>(); + + @Override + public String getName() { + return "TestAdvisor"; + } + + @Override + public int getOrder() { + return 0; + } + + @Override + public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) { + var r = callAdvisorChain.nextCall(chatClientRequest); + this.context.putAll(r.context()); + return r; + } + + public Map getContext() { + return this.context; + } + + }; + +} diff --git a/spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java b/spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java index f5680b03e7c..4430ef54992 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/converter/BeanOutputConverter.java @@ -194,6 +194,8 @@ private void generateSchema() { .with(jacksonModule) .with(Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT); + configBuilder.forFields().withRequiredCheck(f -> true); + if (KotlinDetector.isKotlinReflectPresent()) { configBuilder.with(new KotlinModule()); } diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/tool/StructuredOutputChatOptions.java b/spring-ai-model/src/main/java/org/springframework/ai/model/tool/StructuredOutputChatOptions.java new file mode 100644 index 00000000000..1f2d1c1417b --- /dev/null +++ b/spring-ai-model/src/main/java/org/springframework/ai/model/tool/StructuredOutputChatOptions.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025-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.model.tool; + +import org.springframework.ai.chat.prompt.ChatOptions; + +/** + * Mixin interface for ChatModels that support structured output. Provides an unified way + * to set and get the output JSON schema. + * + * @author Christian Tzolov + */ +public interface StructuredOutputChatOptions extends ChatOptions { + + String getOutputSchema(); + + void setOutputSchema(String outputSchema); + +} From 6f5480c5c53938339fc604b429f922dbdc3defb2 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sun, 16 Nov 2025 22:45:56 +0100 Subject: [PATCH 2/6] Fix broken tests Signed-off-by: Christian Tzolov --- .../ai/converter/BeanOutputConverterTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java b/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java index 9066aa2c814..b9b8f473aa6 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/converter/BeanOutputConverterTest.java @@ -388,6 +388,7 @@ void formatClassType() { "type" : "string" } }, + "required" : [ "someString" ], "additionalProperties" : false }``` """); @@ -414,6 +415,7 @@ void formatTypeReference() { "type" : "string" } }, + "required" : [ "someString" ], "additionalProperties" : false }``` """); @@ -442,6 +444,7 @@ void formatTypeReferenceArray() { "type" : "string" } }, + "required" : [ "someString" ], "additionalProperties" : false } }``` @@ -461,6 +464,7 @@ void formatClassTypeWithAnnotations() { "description" : "string_property_description" } }, + "required" : [ "string_property" ], "additionalProperties" : false }``` """); @@ -481,6 +485,7 @@ void formatTypeReferenceWithAnnotations() { "description" : "string_property_description" } }, + "required" : [ "string_property" ], "additionalProperties" : false }``` """); From 912aca25af4bde4eb6d6fcdae78c1f453cb1af18 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sun, 16 Nov 2025 23:06:42 +0100 Subject: [PATCH 3/6] fix kotlin tests Signed-off-by: Christian Tzolov --- .../springframework/ai/converter/BeanOutputConverterTests.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-ai-model/src/test/kotlin/org/springframework/ai/converter/BeanOutputConverterTests.kt b/spring-ai-model/src/test/kotlin/org/springframework/ai/converter/BeanOutputConverterTests.kt index 1bea428d430..b7dda0651a1 100644 --- a/spring-ai-model/src/test/kotlin/org/springframework/ai/converter/BeanOutputConverterTests.kt +++ b/spring-ai-model/src/test/kotlin/org/springframework/ai/converter/BeanOutputConverterTests.kt @@ -22,7 +22,7 @@ class KotlinBeanOutputConverterTests { val required = schemaNode["required"] assertThat(required).isNotNull assertThat(required.toString()).contains("bar") - assertThat(required.toString()).doesNotContain("baz") + assertThat(required.toString()).contains("baz") val properties = schemaNode["properties"] assertThat(properties["bar"]["type"].asText()).isEqualTo("string") @@ -47,7 +47,7 @@ class KotlinBeanOutputConverterTests { val required = schemaNode["required"] assertThat(required).isNotNull assertThat(required.toString()).contains("bar") - assertThat(required.toString()).doesNotContain("baz") + assertThat(required.toString()).contains("baz") val properties = schemaNode["properties"] assertThat(properties["bar"]["type"].asText()).isEqualTo("string") From d6fef7f4f535c2b54f9f1354ec38d64dcd56d2a0 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sun, 16 Nov 2025 23:43:16 +0100 Subject: [PATCH 4/6] Add documentation Signed-off-by: Christian Tzolov --- .../modules/ROOT/pages/api/chatclient.adoc | 17 ++++++ .../api/structured-output-converter.adoc | 61 ++++++++++++++----- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc index 6ea065e0df8..d2dc0e8e3f2 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc @@ -283,6 +283,23 @@ List actorFilms = chatClient.prompt() .entity(new ParameterizedTypeReference>() {}); ---- +==== Native Structured Output + +As more AI models support structured output natively, you can take advantage of this feature by using the `AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT` advisor parameter when calling the `ChatClient`. +You can use the `defaultAdvisors()` method on the `ChatClient.Builder` to set this parameter globally for all calls or set it per call as shown below: + +[source,java] +---- +ActorFilms actorFilms = chatClient.prompt() + .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .user("Generate the filmography for a random actor.") + .call() + .entity(ActorFilms.class); +---- + +NOTE: Some AI models such as OpenAI don't support arrays of objects natively. +In such cases, you can use the Spring AI default structured output conversion. + === Streaming Responses The `stream()` method lets you get an asynchronous response as shown below: diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc index 4231e208a6a..bec651d85f7 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc @@ -2,9 +2,6 @@ = Structured Output Converter -NOTE: As of 02.05.2024 the old `OutputParser`, `BeanOutputParser`, `ListOutputParser` and `MapOutputParser` classes are deprecated in favor of the new `StructuredOutputConverter`, `BeanOutputConverter`, `ListOutputConverter` and `MapOutputConverter` implementations. -the latter are drop-in replacements for the former ones and provide the same functionality. The reason for the change was primarily naming, as there isn't any parsing being done, but also have aligned with the Spring `org.springframework.core.convert.converter` package bringing in some improved functionality. - The ability of LLMs to produce structured outputs is important for downstream applications that rely on reliably parsing output values. Developers want to quickly turn results from an AI model into data types, such as JSON, XML or Java classes, that can be passed to other application functions and methods. @@ -17,6 +14,8 @@ Generating structured outputs from Large Language Models (LLMs) using generic co Before the LLM call, the converter appends format instructions to the prompt, providing explicit guidance to the models on generating the desired output structure. These instructions act as a blueprint, shaping the model's response to conform to the specified format. +NOTE: As more AI models natively support structured outputs, you can leverage this capability using the xref:api/chatclient.adoc#_native_structured_output[Native Structured Output] feature with `AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT`. This approach uses the generated JSON schema directly with the model's native structured output API, eliminating the need for pre-prompt formatting instructions and providing more reliable results. + After the LLM call, the converter takes the model's output text and transforms it into instances of the structured type. This conversion process involves parsing the raw text output and mapping it to the corresponding structured data representation, such as JSON, XML, or domain-specific data structures. TIP: The `StructuredOutputConverter` is a best effort to convert the model output into a structured output. @@ -253,22 +252,52 @@ Generation generation = this.chatModel.call(this.prompt).getResult(); List list = this.listOutputConverter.convert(this.generation.getOutput().getText()); ---- -== Supported AI Models +== Native Structured Output + +Many modern AI models now provide native support for structured output, which offers more reliable results compared to prompt-based formatting. Spring AI supports this through the xref:api/chatclient.adoc#_native_structured_output[Native Structured Output] feature. + +When using native structured output, the JSON schema generated by `BeanOutputConverter` is sent directly to the model's structured output API, eliminating the need for format instructions in the prompt. This approach provides: + +* **Higher reliability**: The model guarantees output conforming to the schema +* **Cleaner prompts**: No need to append format instructions +* **Better performance**: Models can optimize for structured output internally + +=== Using Native Structured Output + +To enable native structured output, use the `AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT` parameter: + +[source,java] +---- +ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt() + .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .user("Generate the filmography for a random actor.") + .call() + .entity(ActorsFilms.class); +---- + +You can also set this globally using `defaultAdvisors()` on the `ChatClient.Builder`: + +[source,java] +---- +@Bean +ChatClient chatClient(ChatClient.Builder builder) { + return builder + .defaultAdvisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .build(); +} +---- + +=== Supported Models for Native Structured Output + +The following models currently support native structured output: -The following AI Models have been tested to support List, Map and Bean structured outputs. +* **OpenAI**: GPT-4o and later models with JSON Schema support +* **Anthropic**: Claude 3.5 Sonnet and later models +* **Vertex AI Gemini**: Gemini 1.5 Pro and later models -[cols="2,5"] -|==== -| Model | Integration Tests / Samples -| xref:api/chat/openai-chat.adoc[OpenAI] | link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/OpenAiChatModelIT.java[OpenAiChatModelIT] -| xref:api/chat/anthropic-chat.adoc[Anthropic Claude 3] | link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/AnthropicChatModelIT.java[AnthropicChatModelIT.java] -| xref:api/chat/azure-openai-chat.adoc[Azure OpenAI] | link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureOpenAiChatModelIT.java[AzureOpenAiChatModelIT.java] -| xref:api/chat/mistralai-chat.adoc[Mistral AI] | link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-mistral-ai/src/test/java/org/springframework/ai/mistralai/MistralAiChatModelIT.java[MistralAiChatModelIT.java] -| xref:api/chat/ollama-chat.adoc[Ollama] | link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-ollama/src/test/java/org/springframework/ai/ollama/OllamaChatModelIT.java[OllamaChatModelIT.java] -| xref:api/chat/vertexai-gemini-chat.adoc[Vertex AI Gemini] | link:https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java[VertexAiGeminiChatModelIT.java] -|==== +NOTE: Some AI models, such as OpenAI, don't support arrays of objects natively at the top level. In such cases, you can use the Spring AI default structured output conversion (without the native structured output advisor). -== Built-in JSON mode +=== Built-in JSON mode Some AI Models provide dedicated configuration options to generate structured (usually JSON) output. From b22650397296be676a2477c140833e59907d7f3c Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 17 Nov 2025 14:23:37 +0100 Subject: [PATCH 5/6] minor improvements Signed-off-by: Christian Tzolov --- .../client/AnthropicChatClientIT.java | 2 +- .../chat/client/OpenAiChatClientIT.java | 4 ++-- .../gemini/VertexAiGeminiChatModelIT.java | 4 ++-- .../ai/chat/client/AdvisorParams.java | 2 +- .../ai/chat/client/DefaultChatClient.java | 19 ++++++++++++------- .../client/advisor/ChatModelCallAdvisor.java | 19 ++++++++++--------- ...atClientNativeStructuredResponseTests.java | 2 +- .../modules/ROOT/pages/api/chatclient.adoc | 4 ++-- .../api/structured-output-converter.adoc | 8 ++++---- 9 files changed, 35 insertions(+), 29 deletions(-) diff --git a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java index 1c859048c0f..37683611865 100644 --- a/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java +++ b/models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java @@ -124,7 +124,7 @@ void listOutputConverterBean2() { // @formatter:off List actorsFilms = ChatClient.create(this.chatModel).prompt() - .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .options(AnthropicChatOptions.builder() .model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5) .build()) diff --git a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java index 19896b87164..346560fde24 100644 --- a/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java +++ b/models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java @@ -200,7 +200,7 @@ void beanOutputConverterNativeStructuredOutput() { // @formatter:off ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() - .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .user("Generate the filmography for a random actor.") .call() .entity(ActorsFilms.class); @@ -230,7 +230,7 @@ void beanOutputConverterRecordsNativeStructuredOutput() { // @formatter:off ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt() - .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .user("Generate the filmography of 5 movies for Tom Hanks.") .call() .entity(ActorsFilms.class); diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java index 81231e08331..4202067cc1a 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java @@ -243,7 +243,7 @@ void listOutputConverterBean2() { // @formatter:off List actorsFilms = ChatClient.create(this.chatModel).prompt() - .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") .call() .entity(new ParameterizedTypeReference<>() { @@ -259,7 +259,7 @@ void chatClientBeanOutputConverterRecords2() { var chatClient = ChatClient.builder(this.chatModel).build(); ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.") - .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .call() .entity(ActorsFilmsRecord.class); diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java index 9c417369b41..b6e8efc2afd 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/AdvisorParams.java @@ -32,7 +32,7 @@ public final class AdvisorParams { private AdvisorParams() { } - public static final Consumer WITH_NATIVE_STRUCTURED_OUTPUT = a -> a + public static final Consumer ENABLE_NATIVE_STRUCTURED_OUTPUT = a -> a .param(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey(), true); } diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index 7b2f082704b..47b305c8dda 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -475,15 +475,20 @@ public T entity(Class type) { @Nullable private T doSingleWithBeanOutputConverter(StructuredOutputConverter outputConverter) { - if (outputConverter != null && StringUtils.hasText(outputConverter.getFormat())) { + + if (StringUtils.hasText(outputConverter.getFormat())) { + // Used for default struectured output format support, based on prompt + // instructions. this.request.context().put(ChatClientAttributes.OUTPUT_FORMAT.getKey(), outputConverter.getFormat()); + } + + if (this.request.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()) + && outputConverter instanceof BeanOutputConverter beanOutputConverter) { + // Used for native structured output support, e.g. AI model API shoudl + // provide structured output support. + this.request.context() + .put(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), beanOutputConverter.getJsonSchema()); - if (this.request.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()) - && outputConverter instanceof BeanOutputConverter beanOutputConverter) { - this.request.context() - .put(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey(), - beanOutputConverter.getJsonSchema()); - } } var chatResponse = doGetObservableChatClientResponse(this.request).chatResponse(); diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java index bcdfa8e21bd..84e1d867a80 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java @@ -62,22 +62,23 @@ public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAd } private static ChatClientRequest augmentWithFormatInstructions(ChatClientRequest chatClientRequest) { + String outputFormat = (String) chatClientRequest.context().get(ChatClientAttributes.OUTPUT_FORMAT.getKey()); - if (!StringUtils.hasText(outputFormat)) { + String outputSchema = (String) chatClientRequest.context() + .get(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey()); + + if (!StringUtils.hasText(outputFormat) && !StringUtils.hasText(outputSchema)) { return chatClientRequest; } - if (chatClientRequest.prompt().getOptions() instanceof StructuredOutputChatOptions structuredOutputChatOptions - && chatClientRequest.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey())) { + if (chatClientRequest.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()) + && StringUtils.hasText(outputSchema) && chatClientRequest.prompt() + .getOptions() instanceof StructuredOutputChatOptions structuredOutputChatOptions) { - String outputSchema = (String) chatClientRequest.context() - .get(ChatClientAttributes.STRUCTURED_OUTPUT_SCHEMA.getKey()); + structuredOutputChatOptions.setOutputSchema(outputSchema); - if (StringUtils.hasText(outputSchema)) { - structuredOutputChatOptions.setOutputSchema(outputSchema); - return chatClientRequest; - } + return chatClientRequest; } Prompt augmentedPrompt = chatClientRequest.prompt() diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientNativeStructuredResponseTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientNativeStructuredResponseTests.java index 0d254cc6be7..b8d064f494e 100644 --- a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientNativeStructuredResponseTests.java +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/ChatClientNativeStructuredResponseTests.java @@ -111,7 +111,7 @@ public void nativeEntityTest() { .build() .prompt() .options(this.structuredOutputChatOptions) - .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .advisors(textCallAdvisor) .user("Tell me about John") .call() diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc index d2dc0e8e3f2..3d274a9d902 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chatclient.adoc @@ -285,13 +285,13 @@ List actorFilms = chatClient.prompt() ==== Native Structured Output -As more AI models support structured output natively, you can take advantage of this feature by using the `AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT` advisor parameter when calling the `ChatClient`. +As more AI models support structured output natively, you can take advantage of this feature by using the `AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT` advisor parameter when calling the `ChatClient`. You can use the `defaultAdvisors()` method on the `ChatClient.Builder` to set this parameter globally for all calls or set it per call as shown below: [source,java] ---- ActorFilms actorFilms = chatClient.prompt() - .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .user("Generate the filmography for a random actor.") .call() .entity(ActorFilms.class); diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc index bec651d85f7..41db3f7fd52 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/structured-output-converter.adoc @@ -14,7 +14,7 @@ Generating structured outputs from Large Language Models (LLMs) using generic co Before the LLM call, the converter appends format instructions to the prompt, providing explicit guidance to the models on generating the desired output structure. These instructions act as a blueprint, shaping the model's response to conform to the specified format. -NOTE: As more AI models natively support structured outputs, you can leverage this capability using the xref:api/chatclient.adoc#_native_structured_output[Native Structured Output] feature with `AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT`. This approach uses the generated JSON schema directly with the model's native structured output API, eliminating the need for pre-prompt formatting instructions and providing more reliable results. +NOTE: As more AI models natively support structured outputs, you can leverage this capability using the xref:api/chatclient.adoc#_native_structured_output[Native Structured Output] feature with `AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT`. This approach uses the generated JSON schema directly with the model's native structured output API, eliminating the need for pre-prompt formatting instructions and providing more reliable results. After the LLM call, the converter takes the model's output text and transforms it into instances of the structured type. This conversion process involves parsing the raw text output and mapping it to the corresponding structured data representation, such as JSON, XML, or domain-specific data structures. @@ -264,12 +264,12 @@ When using native structured output, the JSON schema generated by `BeanOutputCon === Using Native Structured Output -To enable native structured output, use the `AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT` parameter: +To enable native structured output, use the `AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT` parameter: [source,java] ---- ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt() - .advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .user("Generate the filmography for a random actor.") .call() .entity(ActorsFilms.class); @@ -282,7 +282,7 @@ You can also set this globally using `defaultAdvisors()` on the `ChatClient.Buil @Bean ChatClient chatClient(ChatClient.Builder builder) { return builder - .defaultAdvisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT) + .defaultAdvisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) .build(); } ---- From d3bad126668c8db64296c8ba986dd52188c07aa4 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 18 Nov 2025 09:40:14 +0100 Subject: [PATCH 6/6] feat: add structured output support to Google GenAI chat model - Implement StructuredOutputChatOptions interface in GoogleGenAiChatOptions - Add responseSchema field and related getter/setter methods - Add outputSchema bridge methods for unified Spring AI API - Update GoogleGenAiChatModel to handle responseSchema configuration - Add integration tests for both native and unified structured output APIs - Include tests for ChatClient with native structured output advisor - Update VertexAI Gemini tests with consistent naming and structured output support Signed-off-by: Christian Tzolov --- .../ai/google/genai/GoogleGenAiChatModel.java | 3 + .../google/genai/GoogleGenAiChatOptions.java | 46 ++++++++- .../google/genai/GoogleGenAiChatModelIT.java | 96 +++++++++++++++++++ .../gemini/VertexAiGeminiChatModelIT.java | 77 ++++++++++++--- 4 files changed, 203 insertions(+), 19 deletions(-) diff --git a/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java b/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java index c5c30791670..629431a3aa4 100644 --- a/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java +++ b/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java @@ -707,6 +707,9 @@ GeminiRequest createGeminiRequest(Prompt prompt) { if (requestOptions.getResponseMimeType() != null) { configBuilder.responseMimeType(requestOptions.getResponseMimeType()); } + if (requestOptions.getResponseSchema() != null) { + configBuilder.responseJsonSchema(jsonToSchema(requestOptions.getResponseSchema())); + } if (requestOptions.getFrequencyPenalty() != null) { configBuilder.frequencyPenalty(requestOptions.getFrequencyPenalty().floatValue()); } diff --git a/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java b/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java index 4799e3101a2..215d2955e5c 100644 --- a/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java +++ b/models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java @@ -32,6 +32,7 @@ import org.springframework.ai.google.genai.GoogleGenAiChatModel.ChatModel; import org.springframework.ai.google.genai.common.GoogleGenAiSafetySetting; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; import org.springframework.lang.Nullable; @@ -49,7 +50,7 @@ * @since 1.0.0 */ @JsonInclude(Include.NON_NULL) -public class GoogleGenAiChatOptions implements ToolCallingChatOptions { +public class GoogleGenAiChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions { // https://cloud.google.com/vertex-ai/docs/reference/rest/v1/GenerationConfig @@ -97,6 +98,11 @@ public class GoogleGenAiChatOptions implements ToolCallingChatOptions { */ private @JsonProperty("responseMimeType") String responseMimeType; + /** + * Optional. Geminie response schema. + */ + private @JsonProperty("responseSchema") String responseSchema; + /** * Optional. Frequency penalties. */ @@ -199,8 +205,8 @@ public static GoogleGenAiChatOptions fromOptions(GoogleGenAiChatOptions fromOpti options.setModel(fromOptions.getModel()); options.setToolCallbacks(fromOptions.getToolCallbacks()); options.setResponseMimeType(fromOptions.getResponseMimeType()); + options.setResponseSchema(fromOptions.getResponseSchema()); options.setToolNames(fromOptions.getToolNames()); - options.setResponseMimeType(fromOptions.getResponseMimeType()); options.setGoogleSearchRetrieval(fromOptions.getGoogleSearchRetrieval()); options.setSafetySettings(fromOptions.getSafetySettings()); options.setInternalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled()); @@ -295,6 +301,14 @@ public void setResponseMimeType(String mimeType) { this.responseMimeType = mimeType; } + public String getResponseSchema() { + return this.responseSchema; + } + + public void setResponseSchema(String responseSchema) { + this.responseSchema = responseSchema; + } + @Override public List getToolCallbacks() { return this.toolCallbacks; @@ -433,6 +447,18 @@ public void setToolContext(Map toolContext) { this.toolContext = toolContext; } + @Override + public String getOutputSchema() { + return this.getResponseSchema(); + } + + @Override + @JsonIgnore + public void setOutputSchema(String jsonSchemaText) { + this.setResponseSchema(jsonSchemaText); + this.setResponseMimeType("application/json"); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -450,6 +476,7 @@ public boolean equals(Object o) { && Objects.equals(this.thinkingBudget, that.thinkingBudget) && Objects.equals(this.maxOutputTokens, that.maxOutputTokens) && Objects.equals(this.model, that.model) && Objects.equals(this.responseMimeType, that.responseMimeType) + && Objects.equals(this.responseSchema, that.responseSchema) && Objects.equals(this.toolCallbacks, that.toolCallbacks) && Objects.equals(this.toolNames, that.toolNames) && Objects.equals(this.safetySettings, that.safetySettings) @@ -461,8 +488,9 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(this.stopSequences, this.temperature, this.topP, this.topK, this.candidateCount, this.frequencyPenalty, this.presencePenalty, this.thinkingBudget, this.maxOutputTokens, this.model, - this.responseMimeType, this.toolCallbacks, this.toolNames, this.googleSearchRetrieval, - this.safetySettings, this.internalToolExecutionEnabled, this.toolContext, this.labels); + this.responseMimeType, this.responseSchema, this.toolCallbacks, this.toolNames, + this.googleSearchRetrieval, this.safetySettings, this.internalToolExecutionEnabled, this.toolContext, + this.labels); } @Override @@ -548,6 +576,16 @@ public Builder responseMimeType(String mimeType) { return this; } + public Builder responseSchema(String responseSchema) { + this.options.setResponseSchema(responseSchema); + return this; + } + + public Builder outputSchema(String jsonSchema) { + this.options.setOutputSchema(jsonSchema); + return this; + } + public Builder toolCallbacks(List toolCallbacks) { this.options.toolCallbacks = toolCallbacks; return this; diff --git a/models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java b/models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java index 157d11b26e1..65902b4f6b6 100644 --- a/models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java +++ b/models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java @@ -20,6 +20,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -31,12 +32,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.client.AdvisorParams; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.chat.prompt.SystemPromptTemplate; @@ -53,6 +56,7 @@ import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Bean; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; @@ -206,6 +210,98 @@ void beanOutputConverterRecords() { assertThat(actorsFilms.movies()).hasSize(5); } + @Test + void beanOutputConverterRecordsWithResponseSchema() { + // Use the Google GenAI API to set the response schema + beanOutputConverterRecordsWithStructuredOutput(jsonSchema -> GoogleGenAiChatOptions.builder() + .responseSchema(jsonSchema) + .responseMimeType("application/json") + .build()); + } + + @Test + void beanOutputConverterRecordsWithOutputSchema() { + // Use the unified Spring AI API (StructuredOutputChatOptions) to set the output + // schema. + beanOutputConverterRecordsWithStructuredOutput( + jsonSchema -> GoogleGenAiChatOptions.builder().outputSchema(jsonSchema).build()); + } + + private void beanOutputConverterRecordsWithStructuredOutput(Function chatOptionsProvider) { + + BeanOutputConverter outputConvert = new BeanOutputConverter<>(ActorsFilmsRecord.class); + + String schema = outputConvert.getJsonSchema(); + + Prompt prompt = Prompt.builder() + .content("Generate the filmography of 5 movies for Tom Hanks.") + .chatOptions(chatOptionsProvider.apply(schema)) + .build(); + + Generation generation = this.chatModel.call(prompt).getResult(); + + ActorsFilmsRecord actorsFilms = outputConvert.convert(generation.getOutput().getText()); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void chatClientBeanOutputConverterRecords() { + + var chatClient = ChatClient.builder(this.chatModel).build(); + + ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.") + .call() + .entity(ActorsFilmsRecord.class); + + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void chatClientBeanOutputConverterRecordsNative() { + + var chatClient = ChatClient.builder(this.chatModel).build(); + + ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.") + // forces native structured output handling + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) + .call() + .entity(ActorsFilmsRecord.class); + + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void listOutputConverterBean() { + + // @formatter:off + List actorsFilms = ChatClient.create(this.chatModel).prompt() + .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + assertThat(actorsFilms).hasSize(2); + } + + @Test + void listOutputConverterBeanNative() { + + // @formatter:off + List actorsFilms = ChatClient.create(this.chatModel).prompt() + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) + .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.") + .call() + .entity(new ParameterizedTypeReference<>() { + }); + // @formatter:on + + assertThat(actorsFilms).hasSize(2); + } + @Test void textStream() { diff --git a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java index 4202067cc1a..87edaa1bb33 100644 --- a/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java +++ b/models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java @@ -20,9 +20,11 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.cloud.vertexai.Transport; import com.google.cloud.vertexai.VertexAI; import io.micrometer.observation.ObservationRegistry; @@ -37,6 +39,7 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.chat.prompt.SystemPromptTemplate; @@ -46,9 +49,11 @@ import org.springframework.ai.converter.MapOutputConverter; import org.springframework.ai.model.tool.ToolCallingManager; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.util.json.schema.JsonSchemaGenerator; import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel.ChatModel; import org.springframework.ai.vertexai.gemini.api.VertexAiGeminiApi; import org.springframework.ai.vertexai.gemini.common.VertexAiGeminiSafetySetting; +import org.springframework.ai.vertexai.gemini.schema.JsonSchemaConverter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringBootConfiguration; @@ -211,6 +216,48 @@ void beanOutputConverterRecords() { assertThat(actorsFilms.movies()).hasSize(5); } + @Test + void beanOutputConverterRecordsWithResponseSchema() { + + // Use the Google GenAI API to set the response schema + beanOutputConverterRecordsWithStructuredOutput(jsonSchemaText -> { + ObjectNode jsonSchema = JsonSchemaConverter.fromJson(jsonSchemaText); + ObjectNode openApiSchema = JsonSchemaConverter.convertToOpenApiSchema(jsonSchema); + JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema); + + return VertexAiGeminiChatOptions.builder() + .responseSchema(openApiSchema.toString()) + .responseMimeType("application/json") + .build(); + }); + } + + @Test + void beanOutputConverterRecordsWithOutputSchema() { + // Use the unified Spring AI API (StructuredOutputChatOptions) to set the output + // schema. + beanOutputConverterRecordsWithStructuredOutput( + jsonSchema -> VertexAiGeminiChatOptions.builder().outputSchema(jsonSchema).build()); + } + + private void beanOutputConverterRecordsWithStructuredOutput(Function chatOptionsProvider) { + + BeanOutputConverter outputConvert = new BeanOutputConverter<>(ActorsFilmsRecord.class); + + String schema = outputConvert.getJsonSchema(); + + Prompt prompt = Prompt.builder() + .content("Generate the filmography of 5 movies for Tom Hanks.") + .chatOptions(chatOptionsProvider.apply(schema)) + .build(); + + Generation generation = this.chatModel.call(prompt).getResult(); + + ActorsFilmsRecord actorsFilms = outputConvert.convert(generation.getOutput().getText()); + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + @Test void chatClientBeanOutputConverterRecords() { @@ -224,6 +271,20 @@ void chatClientBeanOutputConverterRecords() { assertThat(actorsFilms.movies()).hasSize(5); } + @Test + void chatClientBeanOutputConverterRecordsNative() { + + var chatClient = ChatClient.builder(this.chatModel).build(); + + ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.") + .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) + .call() + .entity(ActorsFilmsRecord.class); + + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + @Test void listOutputConverterBean() { @@ -239,7 +300,7 @@ void listOutputConverterBean() { } @Test - void listOutputConverterBean2() { + void listOutputConverterBeanNative() { // @formatter:off List actorsFilms = ChatClient.create(this.chatModel).prompt() @@ -253,20 +314,6 @@ void listOutputConverterBean2() { assertThat(actorsFilms).hasSize(2); } - @Test - void chatClientBeanOutputConverterRecords2() { - - var chatClient = ChatClient.builder(this.chatModel).build(); - - ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.") - .advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT) - .call() - .entity(ActorsFilmsRecord.class); - - assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); - assertThat(actorsFilms.movies()).hasSize(5); - } - @Test void textStream() {