Skip to content

Commit 195c4fd

Browse files
tzolovilayaperumalg
authored andcommitted
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 <christian.tzolov@broadcom.com> Add documentation feat: add structured output support to Google GenAI chat model - Implement StructuredOutputChatOptions interface in GoogleGenAiChatOptions - Add responseSchema field and related getter/setter methods - Add outputSchema bridge methods for unified Spring AI API - Update GoogleGenAiChatModel to handle responseSchema configuration - Add integration tests for both native and unified structured output APIs - Include tests for ChatClient with native structured output advisor - Update VertexAI Gemini tests with consistent naming and structured output support Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
1 parent 8a6e39d commit 195c4fd

File tree

21 files changed

+773
-48
lines changed

21 files changed

+773
-48
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
@@ -84,7 +84,7 @@ public static Builder builder() {
8484

8585
public static final String DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
8686

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

8989
public static final String BETA_EXTENDED_CACHE_TTL = "extended-cache-ttl-2025-04-11";
9090

@@ -542,18 +542,20 @@ public record ChatCompletionRequest(
542542
@JsonProperty("top_k") Integer topK,
543543
@JsonProperty("tools") List<Tool> tools,
544544
@JsonProperty("tool_choice") ToolChoice toolChoice,
545-
@JsonProperty("thinking") ThinkingConfig thinking) {
545+
@JsonProperty("thinking") ThinkingConfig thinking,
546+
@JsonProperty("output_format") OutputFormat outputFormat) {
546547
// @formatter:on
547548

548549
public ChatCompletionRequest(String model, List<AnthropicMessage> messages, Object system, Integer maxTokens,
549550
Double temperature, Boolean stream) {
550-
this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null);
551+
this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null,
552+
null);
551553
}
552554

553555
public ChatCompletionRequest(String model, List<AnthropicMessage> messages, Object system, Integer maxTokens,
554556
List<String> stopSequences, Double temperature, Boolean stream) {
555557
this(model, messages, system, maxTokens, null, stopSequences, stream, temperature, null, null, null, null,
556-
null);
558+
null, null);
557559
}
558560

559561
public static ChatCompletionRequestBuilder builder() {
@@ -564,6 +566,15 @@ public static ChatCompletionRequestBuilder from(ChatCompletionRequest request) {
564566
return new ChatCompletionRequestBuilder(request);
565567
}
566568

569+
@JsonInclude(Include.NON_NULL)
570+
public record OutputFormat(@JsonProperty("type") String type,
571+
@JsonProperty("schema") Map<String, Object> schema) {
572+
573+
public OutputFormat(String jsonSchema) {
574+
this("json_schema", ModelOptionsUtils.jsonToMap(jsonSchema));
575+
}
576+
}
577+
567578
/**
568579
* Metadata about the request.
569580
*
@@ -631,6 +642,8 @@ public static final class ChatCompletionRequestBuilder {
631642

632643
private ChatCompletionRequest.ThinkingConfig thinking;
633644

645+
private ChatCompletionRequest.OutputFormat outputFormat;
646+
634647
private ChatCompletionRequestBuilder() {
635648
}
636649

@@ -648,6 +661,7 @@ private ChatCompletionRequestBuilder(ChatCompletionRequest request) {
648661
this.tools = request.tools;
649662
this.toolChoice = request.toolChoice;
650663
this.thinking = request.thinking;
664+
this.outputFormat = request.outputFormat;
651665
}
652666

653667
public ChatCompletionRequestBuilder model(ChatModel model) {
@@ -725,10 +739,15 @@ public ChatCompletionRequestBuilder thinking(ThinkingType type, Integer budgetTo
725739
return this;
726740
}
727741

742+
public ChatCompletionRequestBuilder outputFormat(ChatCompletionRequest.OutputFormat outputFormat) {
743+
this.outputFormat = outputFormat;
744+
return this;
745+
}
746+
728747
public ChatCompletionRequest build() {
729748
return new ChatCompletionRequest(this.model, this.messages, this.system, this.maxTokens, this.metadata,
730749
this.stopSequences, this.stream, this.temperature, this.topP, this.topK, this.tools,
731-
this.toolChoice, this.thinking);
750+
this.toolChoice, this.thinking, this.outputFormat);
732751
}
733752

734753
}

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.ENABLE_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-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatModel.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,9 @@ GeminiRequest createGeminiRequest(Prompt prompt) {
707707
if (requestOptions.getResponseMimeType() != null) {
708708
configBuilder.responseMimeType(requestOptions.getResponseMimeType());
709709
}
710+
if (requestOptions.getResponseSchema() != null) {
711+
configBuilder.responseJsonSchema(jsonToSchema(requestOptions.getResponseSchema()));
712+
}
710713
if (requestOptions.getFrequencyPenalty() != null) {
711714
configBuilder.frequencyPenalty(requestOptions.getFrequencyPenalty().floatValue());
712715
}

models/spring-ai-google-genai/src/main/java/org/springframework/ai/google/genai/GoogleGenAiChatOptions.java

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
import org.springframework.ai.google.genai.GoogleGenAiChatModel.ChatModel;
3434
import org.springframework.ai.google.genai.common.GoogleGenAiSafetySetting;
35+
import org.springframework.ai.model.tool.StructuredOutputChatOptions;
3536
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3637
import org.springframework.ai.tool.ToolCallback;
3738
import org.springframework.lang.Nullable;
@@ -49,7 +50,7 @@
4950
* @since 1.0.0
5051
*/
5152
@JsonInclude(Include.NON_NULL)
52-
public class GoogleGenAiChatOptions implements ToolCallingChatOptions {
53+
public class GoogleGenAiChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions {
5354

5455
// https://cloud.google.com/vertex-ai/docs/reference/rest/v1/GenerationConfig
5556

@@ -97,6 +98,11 @@ public class GoogleGenAiChatOptions implements ToolCallingChatOptions {
9798
*/
9899
private @JsonProperty("responseMimeType") String responseMimeType;
99100

101+
/**
102+
* Optional. Geminie response schema.
103+
*/
104+
private @JsonProperty("responseSchema") String responseSchema;
105+
100106
/**
101107
* Optional. Frequency penalties.
102108
*/
@@ -199,8 +205,8 @@ public static GoogleGenAiChatOptions fromOptions(GoogleGenAiChatOptions fromOpti
199205
options.setModel(fromOptions.getModel());
200206
options.setToolCallbacks(fromOptions.getToolCallbacks());
201207
options.setResponseMimeType(fromOptions.getResponseMimeType());
208+
options.setResponseSchema(fromOptions.getResponseSchema());
202209
options.setToolNames(fromOptions.getToolNames());
203-
options.setResponseMimeType(fromOptions.getResponseMimeType());
204210
options.setGoogleSearchRetrieval(fromOptions.getGoogleSearchRetrieval());
205211
options.setSafetySettings(fromOptions.getSafetySettings());
206212
options.setInternalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled());
@@ -295,6 +301,14 @@ public void setResponseMimeType(String mimeType) {
295301
this.responseMimeType = mimeType;
296302
}
297303

304+
public String getResponseSchema() {
305+
return this.responseSchema;
306+
}
307+
308+
public void setResponseSchema(String responseSchema) {
309+
this.responseSchema = responseSchema;
310+
}
311+
298312
@Override
299313
public List<ToolCallback> getToolCallbacks() {
300314
return this.toolCallbacks;
@@ -433,6 +447,18 @@ public void setToolContext(Map<String, Object> toolContext) {
433447
this.toolContext = toolContext;
434448
}
435449

450+
@Override
451+
public String getOutputSchema() {
452+
return this.getResponseSchema();
453+
}
454+
455+
@Override
456+
@JsonIgnore
457+
public void setOutputSchema(String jsonSchemaText) {
458+
this.setResponseSchema(jsonSchemaText);
459+
this.setResponseMimeType("application/json");
460+
}
461+
436462
@Override
437463
public boolean equals(Object o) {
438464
if (this == o) {
@@ -450,6 +476,7 @@ public boolean equals(Object o) {
450476
&& Objects.equals(this.thinkingBudget, that.thinkingBudget)
451477
&& Objects.equals(this.maxOutputTokens, that.maxOutputTokens) && Objects.equals(this.model, that.model)
452478
&& Objects.equals(this.responseMimeType, that.responseMimeType)
479+
&& Objects.equals(this.responseSchema, that.responseSchema)
453480
&& Objects.equals(this.toolCallbacks, that.toolCallbacks)
454481
&& Objects.equals(this.toolNames, that.toolNames)
455482
&& Objects.equals(this.safetySettings, that.safetySettings)
@@ -461,8 +488,9 @@ public boolean equals(Object o) {
461488
public int hashCode() {
462489
return Objects.hash(this.stopSequences, this.temperature, this.topP, this.topK, this.candidateCount,
463490
this.frequencyPenalty, this.presencePenalty, this.thinkingBudget, this.maxOutputTokens, this.model,
464-
this.responseMimeType, this.toolCallbacks, this.toolNames, this.googleSearchRetrieval,
465-
this.safetySettings, this.internalToolExecutionEnabled, this.toolContext, this.labels);
491+
this.responseMimeType, this.responseSchema, this.toolCallbacks, this.toolNames,
492+
this.googleSearchRetrieval, this.safetySettings, this.internalToolExecutionEnabled, this.toolContext,
493+
this.labels);
466494
}
467495

468496
@Override
@@ -548,6 +576,16 @@ public Builder responseMimeType(String mimeType) {
548576
return this;
549577
}
550578

579+
public Builder responseSchema(String responseSchema) {
580+
this.options.setResponseSchema(responseSchema);
581+
return this;
582+
}
583+
584+
public Builder outputSchema(String jsonSchema) {
585+
this.options.setOutputSchema(jsonSchema);
586+
return this;
587+
}
588+
551589
public Builder toolCallbacks(List<ToolCallback> toolCallbacks) {
552590
this.options.toolCallbacks = toolCallbacks;
553591
return this;

0 commit comments

Comments
 (0)