Skip to content

Commit 040697e

Browse files
committed
feat: Extend ChatClient to leverage native structured output from ChatModel implementations
Add ChatClient support for utilizing native structured output capabilities provided by underlying ChatModel implementations (Anthropic, OpenAI, Vertex AI Gemini). Changes: - Introduce StructuredOutputChatOptions interface for ChatModels to advertise native support - Extend ChatClient to detect and utilize native structured output when available - Add STRUCTURED_OUTPUT_SCHEMA attribute to ChatClientAttributes for schema propagation - Enhance ChatModelCallAdvisor to configure native structured output instead of prompt-based formatting - Update DefaultChatClient to capture and propagate output schema to chat options - Implement StructuredOutputChatOptions in AnthropicChatOptions with outputFormat field - Implement StructuredOutputChatOptions in OpenAiChatOptions using responseFormat - Implement StructuredOutputChatOptions in VertexAiGeminiChatOptions with responseSchema - Update AnthropicApi to include structured-outputs-2025-11-13 beta version - Modify BeanOutputConverter to mark all fields as required by default - Add comprehensive integration tests across all providers When nativeStructuredOutput=true is set in chat options, ChatClient automatically configures the underlying ChatModel's native structured output capabilities instead of using traditional prompt-based JSON formatting instructions. Closes #4463 Fixes #4889 Part of #2787 Signed-off-by: Christian Tzolov <[email protected]>
1 parent 10bc0a7 commit 040697e

File tree

12 files changed

+354
-26
lines changed

12 files changed

+354
-26
lines changed

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

Lines changed: 62 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,13 @@ 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+
126+
private Boolean nativeStructuredOutput;
127+
118128
// @formatter:on
119129

120130
public static Builder builder() {
@@ -141,6 +151,8 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions)
141151
.cacheOptions(fromOptions.getCacheOptions())
142152
.citationDocuments(fromOptions.getCitationDocuments() != null
143153
? new ArrayList<>(fromOptions.getCitationDocuments()) : null)
154+
.outputFormat(fromOptions.getOutputFormat())
155+
.nativeStructuredOutput(fromOptions.isNativeStructuredOutput())
144156
.build();
145157
}
146158

@@ -325,6 +337,37 @@ public void validateCitationConsistency() {
325337
}
326338
}
327339

340+
public OutputFormat getOutputFormat() {
341+
return this.outputFormat;
342+
}
343+
344+
public void setOutputFormat(OutputFormat outputFormat) {
345+
Assert.notNull(outputFormat, "outputFormat cannot be null");
346+
this.outputFormat = outputFormat;
347+
}
348+
349+
@Override
350+
@JsonIgnore
351+
public String getOutputSchema() {
352+
return this.getOutputFormat() != null ? ModelOptionsUtils.toJsonString(this.getOutputFormat().schema()) : null;
353+
}
354+
355+
@Override
356+
@JsonIgnore
357+
public void setOutputSchema(String outputSchema) {
358+
this.setOutputFormat(new OutputFormat(outputSchema));
359+
}
360+
361+
@Override
362+
public Boolean isNativeStructuredOutput() {
363+
return this.nativeStructuredOutput;
364+
}
365+
366+
@Override
367+
public void setNativeStructuredOutput(Boolean nativeStructuredOutput) {
368+
this.nativeStructuredOutput = nativeStructuredOutput;
369+
}
370+
328371
@Override
329372
@SuppressWarnings("unchecked")
330373
public AnthropicChatOptions copy() {
@@ -351,6 +394,8 @@ public boolean equals(Object o) {
351394
&& Objects.equals(this.toolContext, that.toolContext)
352395
&& Objects.equals(this.httpHeaders, that.httpHeaders)
353396
&& Objects.equals(this.cacheOptions, that.cacheOptions)
397+
&& Objects.equals(this.outputFormat, that.outputFormat)
398+
&& Objects.equals(this.nativeStructuredOutput, that.nativeStructuredOutput)
354399
&& Objects.equals(this.citationDocuments, that.citationDocuments);
355400
}
356401

@@ -359,7 +404,7 @@ public int hashCode() {
359404
return Objects.hash(this.model, this.maxTokens, this.metadata, this.stopSequences, this.temperature, this.topP,
360405
this.topK, this.toolChoice, this.thinking, this.toolCallbacks, this.toolNames,
361406
this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions,
362-
this.citationDocuments);
407+
this.outputFormat, this.nativeStructuredOutput, this.citationDocuments);
363408
}
364409

365410
public static final class Builder {
@@ -501,6 +546,21 @@ public Builder addCitationDocument(CitationDocument document) {
501546
return this;
502547
}
503548

549+
public Builder outputFormat(OutputFormat outputFormat) {
550+
this.options.outputFormat = outputFormat;
551+
return this;
552+
}
553+
554+
public Builder outputSchema(String outputSchema) {
555+
this.options.setOutputSchema(outputSchema);
556+
return this;
557+
}
558+
559+
public Builder nativeStructuredOutput(Boolean nativeStructuredOutput) {
560+
this.options.nativeStructuredOutput = nativeStructuredOutput;
561+
return this;
562+
}
563+
504564
public AnthropicChatOptions build() {
505565
this.options.validateCitationConsistency();
506566
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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,24 @@ void listOutputConverterBean() {
118118
assertThat(actorsFilms).hasSize(2);
119119
}
120120

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

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

Lines changed: 42 additions & 2 deletions
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

@@ -294,6 +296,9 @@ public class OpenAiChatOptions implements ToolCallingChatOptions {
294296
*/
295297
private @JsonProperty("extra_body") Map<String, Object> extraBody;
296298

299+
@JsonIgnore
300+
private Boolean nativeStructuredOutput;
301+
297302
// @formatter:on
298303

299304
public static Builder builder() {
@@ -339,6 +344,7 @@ public static OpenAiChatOptions fromOptions(OpenAiChatOptions fromOptions) {
339344
.promptCacheKey(fromOptions.getPromptCacheKey())
340345
.safetyIdentifier(fromOptions.getSafetyIdentifier())
341346
.extraBody(fromOptions.getExtraBody())
347+
.nativeStructuredOutput(fromOptions.isNativeStructuredOutput())
342348
.build();
343349
}
344350

@@ -675,6 +681,28 @@ public void setSafetyIdentifier(String safetyIdentifier) {
675681
this.safetyIdentifier = safetyIdentifier;
676682
}
677683

684+
@Override
685+
@JsonIgnore
686+
public String getOutputSchema() {
687+
return this.getResponseFormat().getSchema();
688+
}
689+
690+
@Override
691+
@JsonIgnore
692+
public void setOutputSchema(String outputSchema) {
693+
this.setResponseFormat(ResponseFormat.builder().type(Type.JSON_SCHEMA).jsonSchema(outputSchema).build());
694+
}
695+
696+
@Override
697+
public Boolean isNativeStructuredOutput() {
698+
return this.nativeStructuredOutput;
699+
}
700+
701+
@Override
702+
public void setNativeStructuredOutput(Boolean nativeStructuredOutput) {
703+
this.nativeStructuredOutput = nativeStructuredOutput;
704+
}
705+
678706
@Override
679707
public OpenAiChatOptions copy() {
680708
return OpenAiChatOptions.fromOptions(this);
@@ -688,7 +716,8 @@ public int hashCode() {
688716
this.user, this.parallelToolCalls, this.toolCallbacks, this.toolNames, this.httpHeaders,
689717
this.internalToolExecutionEnabled, this.toolContext, this.outputModalities, this.outputAudio,
690718
this.store, this.metadata, this.reasoningEffort, this.webSearchOptions, this.verbosity,
691-
this.serviceTier, this.promptCacheKey, this.safetyIdentifier, this.extraBody);
719+
this.serviceTier, this.promptCacheKey, this.safetyIdentifier, this.nativeStructuredOutput,
720+
this.extraBody);
692721
}
693722

694723
@Override
@@ -726,6 +755,7 @@ public boolean equals(Object o) {
726755
&& Objects.equals(this.serviceTier, other.serviceTier)
727756
&& Objects.equals(this.promptCacheKey, other.promptCacheKey)
728757
&& Objects.equals(this.safetyIdentifier, other.safetyIdentifier)
758+
&& Objects.equals(this.nativeStructuredOutput, other.nativeStructuredOutput)
729759
&& Objects.equals(this.extraBody, other.extraBody);
730760
}
731761

@@ -871,6 +901,11 @@ public Builder responseFormat(ResponseFormat responseFormat) {
871901
return this;
872902
}
873903

904+
public Builder outputSchema(String outputSchema) {
905+
this.options.setOutputSchema(outputSchema);
906+
return this;
907+
}
908+
874909
public Builder streamUsage(boolean enableStreamUsage) {
875910
this.options.streamOptions = (enableStreamUsage) ? StreamOptions.INCLUDE_USAGE : null;
876911
return this;
@@ -1009,6 +1044,11 @@ public Builder extraBody(Map<String, Object> extraBody) {
10091044
return this;
10101045
}
10111046

1047+
public Builder nativeStructuredOutput(Boolean nativeStructuredOutput) {
1048+
this.options.nativeStructuredOutput = nativeStructuredOutput;
1049+
return this;
1050+
}
1051+
10121052
public OpenAiChatOptions build() {
10131053
return this.options;
10141054
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
.options(OpenAiChatOptions.builder().nativeStructuredOutput(true).build())
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+
.options(OpenAiChatOptions.builder().nativeStructuredOutput(true).build())
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)