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..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 @@ -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.ENABLE_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-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-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..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 @@ -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.ENABLE_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.ENABLE_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..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; @@ -30,12 +32,14 @@ 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; 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; @@ -45,14 +49,17 @@ 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; 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 +216,104 @@ 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() { + + 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.") + .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() { @@ -436,7 +541,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..b6e8efc2afd --- /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 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/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..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 @@ -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,24 @@ public T entity(Class type) { @Nullable private T doSingleWithBeanOutputConverter(StructuredOutputConverter outputConverter) { - var chatResponse = doGetObservableChatClientResponse(this.request, outputConverter.getFormat()) - .chatResponse(); + + 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()); + + } + + var chatResponse = doGetObservableChatClientResponse(this.request).chatResponse(); + var stringResponse = getContentFromChatResponse(chatResponse); if (stringResponse == null) { return null; @@ -495,15 +519,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..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 @@ -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())) @@ -59,9 +62,22 @@ 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.context().containsKey(ChatClientAttributes.STRUCTURED_OUTPUT_NATIVE.getKey()) + && StringUtils.hasText(outputSchema) && chatClientRequest.prompt() + .getOptions() instanceof StructuredOutputChatOptions structuredOutputChatOptions) { + + structuredOutputChatOptions.setOutputSchema(outputSchema); + return chatClientRequest; } 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..b8d064f494e --- /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.ENABLE_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-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..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 @@ -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.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.ENABLE_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..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 @@ -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.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. 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.ENABLE_NATIVE_STRUCTURED_OUTPUT` parameter: + +[source,java] +---- +ActorsFilms actorsFilms = ChatClient.create(chatModel).prompt() + .advisors(AdvisorParams.ENABLE_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.ENABLE_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. 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); + +} 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 }``` """); 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")