Skip to content

Commit 5aff1d3

Browse files
committed
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 <[email protected]>
1 parent 10bc0a7 commit 5aff1d3

File tree

14 files changed

+509
-25
lines changed

14 files changed

+509
-25
lines changed

models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/AnthropicChatOptions.java

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@
3232

3333
import org.springframework.ai.anthropic.api.AnthropicApi;
3434
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest;
35+
import org.springframework.ai.anthropic.api.AnthropicApi.ChatCompletionRequest.OutputFormat;
3536
import org.springframework.ai.anthropic.api.AnthropicCacheOptions;
3637
import org.springframework.ai.anthropic.api.CitationDocument;
38+
import org.springframework.ai.model.ModelOptionsUtils;
39+
import org.springframework.ai.model.tool.StructuredOutputChatOptions;
3740
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3841
import org.springframework.ai.tool.ToolCallback;
3942
import org.springframework.lang.Nullable;
@@ -51,7 +54,7 @@
5154
* @since 1.0.0
5255
*/
5356
@JsonInclude(Include.NON_NULL)
54-
public class AnthropicChatOptions implements ToolCallingChatOptions {
57+
public class AnthropicChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions {
5558

5659
// @formatter:off
5760
private @JsonProperty("model") String model;
@@ -115,6 +118,11 @@ public void setCacheOptions(AnthropicCacheOptions cacheOptions) {
115118
@JsonIgnore
116119
private Map<String, String> httpHeaders = new HashMap<>();
117120

121+
/**
122+
* The desired response format for structured output.
123+
*/
124+
private @JsonProperty("output_format") OutputFormat outputFormat;
125+
118126
// @formatter:on
119127

120128
public static Builder builder() {
@@ -141,6 +149,7 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions)
141149
.cacheOptions(fromOptions.getCacheOptions())
142150
.citationDocuments(fromOptions.getCitationDocuments() != null
143151
? new ArrayList<>(fromOptions.getCitationDocuments()) : null)
152+
.outputFormat(fromOptions.getOutputFormat())
144153
.build();
145154
}
146155

@@ -325,6 +334,27 @@ public void validateCitationConsistency() {
325334
}
326335
}
327336

337+
public OutputFormat getOutputFormat() {
338+
return this.outputFormat;
339+
}
340+
341+
public void setOutputFormat(OutputFormat outputFormat) {
342+
Assert.notNull(outputFormat, "outputFormat cannot be null");
343+
this.outputFormat = outputFormat;
344+
}
345+
346+
@Override
347+
@JsonIgnore
348+
public String getOutputSchema() {
349+
return this.getOutputFormat() != null ? ModelOptionsUtils.toJsonString(this.getOutputFormat().schema()) : null;
350+
}
351+
352+
@Override
353+
@JsonIgnore
354+
public void setOutputSchema(String outputSchema) {
355+
this.setOutputFormat(new OutputFormat(outputSchema));
356+
}
357+
328358
@Override
329359
@SuppressWarnings("unchecked")
330360
public AnthropicChatOptions copy() {
@@ -351,6 +381,7 @@ public boolean equals(Object o) {
351381
&& Objects.equals(this.toolContext, that.toolContext)
352382
&& Objects.equals(this.httpHeaders, that.httpHeaders)
353383
&& Objects.equals(this.cacheOptions, that.cacheOptions)
384+
&& Objects.equals(this.outputFormat, that.outputFormat)
354385
&& Objects.equals(this.citationDocuments, that.citationDocuments);
355386
}
356387

@@ -359,7 +390,7 @@ public int hashCode() {
359390
return Objects.hash(this.model, this.maxTokens, this.metadata, this.stopSequences, this.temperature, this.topP,
360391
this.topK, this.toolChoice, this.thinking, this.toolCallbacks, this.toolNames,
361392
this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions,
362-
this.citationDocuments);
393+
this.outputFormat, this.citationDocuments);
363394
}
364395

365396
public static final class Builder {
@@ -501,6 +532,16 @@ public Builder addCitationDocument(CitationDocument document) {
501532
return this;
502533
}
503534

535+
public Builder outputFormat(OutputFormat outputFormat) {
536+
this.options.outputFormat = outputFormat;
537+
return this;
538+
}
539+
540+
public Builder outputSchema(String outputSchema) {
541+
this.options.setOutputSchema(outputSchema);
542+
return this;
543+
}
544+
504545
public AnthropicChatOptions build() {
505546
this.options.validateCitationConsistency();
506547
return this.options;

models/spring-ai-anthropic/src/main/java/org/springframework/ai/anthropic/api/AnthropicApi.java

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public static Builder builder() {
8686

8787
public static final String DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
8888

89-
public static final String DEFAULT_ANTHROPIC_BETA_VERSION = "tools-2024-04-04,pdfs-2024-09-25";
89+
public static final String DEFAULT_ANTHROPIC_BETA_VERSION = "tools-2024-04-04,pdfs-2024-09-25,structured-outputs-2025-11-13";
9090

9191
public static final String BETA_EXTENDED_CACHE_TTL = "extended-cache-ttl-2025-04-11";
9292

@@ -530,18 +530,20 @@ public record ChatCompletionRequest(
530530
@JsonProperty("top_k") Integer topK,
531531
@JsonProperty("tools") List<Tool> tools,
532532
@JsonProperty("tool_choice") ToolChoice toolChoice,
533-
@JsonProperty("thinking") ThinkingConfig thinking) {
533+
@JsonProperty("thinking") ThinkingConfig thinking,
534+
@JsonProperty("output_format") OutputFormat outputFormat) {
534535
// @formatter:on
535536

536537
public ChatCompletionRequest(String model, List<AnthropicMessage> messages, Object system, Integer maxTokens,
537538
Double temperature, Boolean stream) {
538-
this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null);
539+
this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null,
540+
null);
539541
}
540542

541543
public ChatCompletionRequest(String model, List<AnthropicMessage> messages, Object system, Integer maxTokens,
542544
List<String> stopSequences, Double temperature, Boolean stream) {
543545
this(model, messages, system, maxTokens, null, stopSequences, stream, temperature, null, null, null, null,
544-
null);
546+
null, null);
545547
}
546548

547549
public static ChatCompletionRequestBuilder builder() {
@@ -552,6 +554,15 @@ public static ChatCompletionRequestBuilder from(ChatCompletionRequest request) {
552554
return new ChatCompletionRequestBuilder(request);
553555
}
554556

557+
@JsonInclude(Include.NON_NULL)
558+
public record OutputFormat(@JsonProperty("type") String type,
559+
@JsonProperty("schema") Map<String, Object> schema) {
560+
561+
public OutputFormat(String jsonSchema) {
562+
this("json_schema", ModelOptionsUtils.jsonToMap(jsonSchema));
563+
}
564+
}
565+
555566
/**
556567
* Metadata about the request.
557568
*
@@ -619,6 +630,8 @@ public static final class ChatCompletionRequestBuilder {
619630

620631
private ChatCompletionRequest.ThinkingConfig thinking;
621632

633+
private ChatCompletionRequest.OutputFormat outputFormat;
634+
622635
private ChatCompletionRequestBuilder() {
623636
}
624637

@@ -636,6 +649,7 @@ private ChatCompletionRequestBuilder(ChatCompletionRequest request) {
636649
this.tools = request.tools;
637650
this.toolChoice = request.toolChoice;
638651
this.thinking = request.thinking;
652+
this.outputFormat = request.outputFormat;
639653
}
640654

641655
public ChatCompletionRequestBuilder model(ChatModel model) {
@@ -713,10 +727,15 @@ public ChatCompletionRequestBuilder thinking(ThinkingType type, Integer budgetTo
713727
return this;
714728
}
715729

730+
public ChatCompletionRequestBuilder outputFormat(ChatCompletionRequest.OutputFormat outputFormat) {
731+
this.outputFormat = outputFormat;
732+
return this;
733+
}
734+
716735
public ChatCompletionRequest build() {
717736
return new ChatCompletionRequest(this.model, this.messages, this.system, this.maxTokens, this.metadata,
718737
this.stopSequences, this.stream, this.temperature, this.topP, this.topK, this.tools,
719-
this.toolChoice, this.thinking);
738+
this.toolChoice, this.thinking, this.outputFormat);
720739
}
721740

722741
}

models/spring-ai-anthropic/src/test/java/org/springframework/ai/anthropic/client/AnthropicChatClientIT.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.springframework.ai.anthropic.AnthropicTestConfiguration;
3636
import org.springframework.ai.anthropic.api.AnthropicApi;
3737
import org.springframework.ai.anthropic.api.tool.MockWeatherService;
38+
import org.springframework.ai.chat.client.AdvisorParams;
3839
import org.springframework.ai.chat.client.ChatClient;
3940
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
4041
import org.springframework.ai.chat.model.ChatModel;
@@ -118,6 +119,25 @@ void listOutputConverterBean() {
118119
assertThat(actorsFilms).hasSize(2);
119120
}
120121

122+
@Test
123+
void listOutputConverterBean2() {
124+
125+
// @formatter:off
126+
List<ActorsFilms> actorsFilms = ChatClient.create(this.chatModel).prompt()
127+
.advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT)
128+
.options(AnthropicChatOptions.builder()
129+
.model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5)
130+
.build())
131+
.user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
132+
.call()
133+
.entity(new ParameterizedTypeReference<>() {
134+
});
135+
// @formatter:on
136+
137+
logger.info("" + actorsFilms);
138+
assertThat(actorsFilms).hasSize(2);
139+
}
140+
121141
@Test
122142
void customOutputConverter() {
123143

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,15 @@
3333
import org.slf4j.LoggerFactory;
3434

3535
import org.springframework.ai.model.ModelOptionsUtils;
36+
import org.springframework.ai.model.tool.StructuredOutputChatOptions;
3637
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3738
import org.springframework.ai.openai.api.OpenAiApi;
3839
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.AudioParameters;
3940
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.StreamOptions;
4041
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.ToolChoiceBuilder;
4142
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest.WebSearchOptions;
4243
import org.springframework.ai.openai.api.ResponseFormat;
44+
import org.springframework.ai.openai.api.ResponseFormat.Type;
4345
import org.springframework.ai.tool.ToolCallback;
4446
import org.springframework.lang.Nullable;
4547
import org.springframework.util.Assert;
@@ -55,7 +57,7 @@
5557
* @since 0.8.0
5658
*/
5759
@JsonInclude(Include.NON_NULL)
58-
public class OpenAiChatOptions implements ToolCallingChatOptions {
60+
public class OpenAiChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions {
5961

6062
private static final Logger logger = LoggerFactory.getLogger(OpenAiChatOptions.class);
6163

@@ -675,6 +677,18 @@ public void setSafetyIdentifier(String safetyIdentifier) {
675677
this.safetyIdentifier = safetyIdentifier;
676678
}
677679

680+
@Override
681+
@JsonIgnore
682+
public String getOutputSchema() {
683+
return this.getResponseFormat().getSchema();
684+
}
685+
686+
@Override
687+
@JsonIgnore
688+
public void setOutputSchema(String outputSchema) {
689+
this.setResponseFormat(ResponseFormat.builder().type(Type.JSON_SCHEMA).jsonSchema(outputSchema).build());
690+
}
691+
678692
@Override
679693
public OpenAiChatOptions copy() {
680694
return OpenAiChatOptions.fromOptions(this);
@@ -871,6 +885,11 @@ public Builder responseFormat(ResponseFormat responseFormat) {
871885
return this;
872886
}
873887

888+
public Builder outputSchema(String outputSchema) {
889+
this.options.setOutputSchema(outputSchema);
890+
return this;
891+
}
892+
874893
public Builder streamUsage(boolean enableStreamUsage) {
875894
this.options.streamOptions = (enableStreamUsage) ? StreamOptions.INCLUDE_USAGE : null;
876895
return this;

models/spring-ai-openai/src/test/java/org/springframework/ai/openai/chat/client/OpenAiChatClientIT.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.slf4j.LoggerFactory;
3333
import reactor.core.publisher.Flux;
3434

35+
import org.springframework.ai.chat.client.AdvisorParams;
3536
import org.springframework.ai.chat.client.ChatClient;
3637
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
3738
import org.springframework.ai.chat.model.ChatResponse;
@@ -97,7 +98,6 @@ void re2() {
9798
logger.info("" + response);
9899
assertThat(response.toLowerCase().replace("(", " ").replace(")", " ").replace("\"", " ").replace("\"", " "))
99100
.contains(" eight", " one", " ten", " nine");
100-
101101
}
102102

103103
@Test
@@ -195,6 +195,21 @@ void beanOutputConverter() {
195195
assertThat(actorsFilms.actor()).isNotBlank();
196196
}
197197

198+
@Test
199+
void beanOutputConverterNativeStructuredOutput() {
200+
201+
// @formatter:off
202+
ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt()
203+
.advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT)
204+
.user("Generate the filmography for a random actor.")
205+
.call()
206+
.entity(ActorsFilms.class);
207+
// @formatter:on
208+
209+
logger.info("" + actorsFilms);
210+
assertThat(actorsFilms.actor()).isNotBlank();
211+
}
212+
198213
@Test
199214
void beanOutputConverterRecords() {
200215

@@ -210,6 +225,22 @@ void beanOutputConverterRecords() {
210225
assertThat(actorsFilms.movies()).hasSize(5);
211226
}
212227

228+
@Test
229+
void beanOutputConverterRecordsNativeStructuredOutput() {
230+
231+
// @formatter:off
232+
ActorsFilms actorsFilms = ChatClient.create(this.chatModel).prompt()
233+
.advisors(AdvisorParams.WITH_NATIVE_STRUCTURED_OUTPUT)
234+
.user("Generate the filmography of 5 movies for Tom Hanks.")
235+
.call()
236+
.entity(ActorsFilms.class);
237+
// @formatter:on
238+
239+
logger.info("" + actorsFilms);
240+
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
241+
assertThat(actorsFilms.movies()).hasSize(5);
242+
}
243+
213244
@Test
214245
void beanStreamOutputConverterRecords() {
215246

0 commit comments

Comments
 (0)