Skip to content

Commit 3c3c3eb

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 a1f32d1 commit 3c3c3eb

File tree

21 files changed

+772
-49
lines changed

21 files changed

+772
-49
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
/**
119127
* Container for Claude Skills to make available in this request.
120128
* Skills are collections of instructions, scripts, and resources that
@@ -150,6 +158,7 @@ public static AnthropicChatOptions fromOptions(AnthropicChatOptions fromOptions)
150158
.cacheOptions(fromOptions.getCacheOptions())
151159
.citationDocuments(fromOptions.getCitationDocuments() != null
152160
? new ArrayList<>(fromOptions.getCitationDocuments()) : null)
161+
.outputFormat(fromOptions.getOutputFormat())
153162
.skillContainer(fromOptions.getSkillContainer())
154163
.build();
155164
}
@@ -343,6 +352,27 @@ public void validateCitationConsistency() {
343352
}
344353
}
345354

355+
public OutputFormat getOutputFormat() {
356+
return this.outputFormat;
357+
}
358+
359+
public void setOutputFormat(OutputFormat outputFormat) {
360+
Assert.notNull(outputFormat, "outputFormat cannot be null");
361+
this.outputFormat = outputFormat;
362+
}
363+
364+
@Override
365+
@JsonIgnore
366+
public String getOutputSchema() {
367+
return this.getOutputFormat() != null ? ModelOptionsUtils.toJsonString(this.getOutputFormat().schema()) : null;
368+
}
369+
370+
@Override
371+
@JsonIgnore
372+
public void setOutputSchema(String outputSchema) {
373+
this.setOutputFormat(new OutputFormat(outputSchema));
374+
}
375+
346376
@Override
347377
@SuppressWarnings("unchecked")
348378
public AnthropicChatOptions copy() {
@@ -369,6 +399,7 @@ public boolean equals(Object o) {
369399
&& Objects.equals(this.toolContext, that.toolContext)
370400
&& Objects.equals(this.httpHeaders, that.httpHeaders)
371401
&& Objects.equals(this.cacheOptions, that.cacheOptions)
402+
&& Objects.equals(this.outputFormat, that.outputFormat)
372403
&& Objects.equals(this.citationDocuments, that.citationDocuments)
373404
&& Objects.equals(this.skillContainer, that.skillContainer);
374405
}
@@ -378,7 +409,7 @@ public int hashCode() {
378409
return Objects.hash(this.model, this.maxTokens, this.metadata, this.stopSequences, this.temperature, this.topP,
379410
this.topK, this.toolChoice, this.thinking, this.toolCallbacks, this.toolNames,
380411
this.internalToolExecutionEnabled, this.toolContext, this.httpHeaders, this.cacheOptions,
381-
this.citationDocuments, this.skillContainer);
412+
this.outputFormat, this.citationDocuments, this.skillContainer);
382413
}
383414

384415
public static final class Builder {
@@ -520,6 +551,16 @@ public Builder addCitationDocument(CitationDocument document) {
520551
return this;
521552
}
522553

554+
public Builder outputFormat(OutputFormat outputFormat) {
555+
this.options.outputFormat = outputFormat;
556+
return this;
557+
}
558+
559+
public Builder outputSchema(String outputSchema) {
560+
this.options.setOutputSchema(outputSchema);
561+
return this;
562+
}
563+
523564
/**
524565
* Set the Skills container for this request.
525566
* @param skillContainer Container with skills to make available

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

Lines changed: 23 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

@@ -202,7 +202,7 @@ public ResponseEntity<ChatCompletionResponse> chatCompletionEntity(ChatCompletio
202202
* @return Returns a {@link Flux} stream from chat completion chunks.
203203
*/
204204
public Flux<ChatCompletionResponse> chatCompletionStream(ChatCompletionRequest chatRequest) {
205-
return chatCompletionStream(chatRequest, new LinkedMultiValueMap<>());
205+
return chatCompletionStream(chatRequest, new HttpHeaders());
206206
}
207207

208208
/**
@@ -837,19 +837,20 @@ public record ChatCompletionRequest(
837837
@JsonProperty("tools") List<Tool> tools,
838838
@JsonProperty("tool_choice") ToolChoice toolChoice,
839839
@JsonProperty("thinking") ThinkingConfig thinking,
840+
@JsonProperty("output_format") OutputFormat outputFormat,
840841
@JsonProperty("container") SkillContainer container) {
841842
// @formatter:on
842843

843844
public ChatCompletionRequest(String model, List<AnthropicMessage> messages, Object system, Integer maxTokens,
844845
Double temperature, Boolean stream) {
845846
this(model, messages, system, maxTokens, null, null, stream, temperature, null, null, null, null, null,
846-
null);
847+
null, null);
847848
}
848849

849850
public ChatCompletionRequest(String model, List<AnthropicMessage> messages, Object system, Integer maxTokens,
850851
List<String> stopSequences, Double temperature, Boolean stream) {
851852
this(model, messages, system, maxTokens, null, stopSequences, stream, temperature, null, null, null, null,
852-
null, null);
853+
null, null, null);
853854
}
854855

855856
public static ChatCompletionRequestBuilder builder() {
@@ -860,6 +861,15 @@ public static ChatCompletionRequestBuilder from(ChatCompletionRequest request) {
860861
return new ChatCompletionRequestBuilder(request);
861862
}
862863

864+
@JsonInclude(Include.NON_NULL)
865+
public record OutputFormat(@JsonProperty("type") String type,
866+
@JsonProperty("schema") Map<String, Object> schema) {
867+
868+
public OutputFormat(String jsonSchema) {
869+
this("json_schema", ModelOptionsUtils.jsonToMap(jsonSchema));
870+
}
871+
}
872+
863873
/**
864874
* Metadata about the request.
865875
*
@@ -929,6 +939,8 @@ public static final class ChatCompletionRequestBuilder {
929939

930940
private SkillContainer container;
931941

942+
private ChatCompletionRequest.OutputFormat outputFormat;
943+
932944
private ChatCompletionRequestBuilder() {
933945
}
934946

@@ -947,6 +959,7 @@ private ChatCompletionRequestBuilder(ChatCompletionRequest request) {
947959
this.toolChoice = request.toolChoice;
948960
this.thinking = request.thinking;
949961
this.container = request.container;
962+
this.outputFormat = request.outputFormat;
950963
}
951964

952965
public ChatCompletionRequestBuilder model(ChatModel model) {
@@ -1036,10 +1049,15 @@ public ChatCompletionRequestBuilder skills(List<Skill> skills) {
10361049
return this;
10371050
}
10381051

1052+
public ChatCompletionRequestBuilder outputFormat(ChatCompletionRequest.OutputFormat outputFormat) {
1053+
this.outputFormat = outputFormat;
1054+
return this;
1055+
}
1056+
10391057
public ChatCompletionRequest build() {
10401058
return new ChatCompletionRequest(this.model, this.messages, this.system, this.maxTokens, this.metadata,
10411059
this.stopSequences, this.stream, this.temperature, this.topP, this.topK, this.tools,
1042-
this.toolChoice, this.thinking, this.container);
1060+
this.toolChoice, this.thinking, this.outputFormat, this.container);
10431061
}
10441062

10451063
}

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
@@ -748,6 +748,9 @@ GeminiRequest createGeminiRequest(Prompt prompt) {
748748
if (requestOptions.getResponseMimeType() != null) {
749749
configBuilder.responseMimeType(requestOptions.getResponseMimeType());
750750
}
751+
if (requestOptions.getResponseSchema() != null) {
752+
configBuilder.responseJsonSchema(jsonToSchema(requestOptions.getResponseSchema()));
753+
}
751754
if (requestOptions.getFrequencyPenalty() != null) {
752755
configBuilder.frequencyPenalty(requestOptions.getFrequencyPenalty().floatValue());
753756
}

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

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.springframework.ai.google.genai.GoogleGenAiChatModel.ChatModel;
3434
import org.springframework.ai.google.genai.common.GoogleGenAiSafetySetting;
3535
import org.springframework.ai.google.genai.common.GoogleGenAiThinkingLevel;
36+
import org.springframework.ai.model.tool.StructuredOutputChatOptions;
3637
import org.springframework.ai.model.tool.ToolCallingChatOptions;
3738
import org.springframework.ai.tool.ToolCallback;
3839
import org.springframework.lang.Nullable;
@@ -50,7 +51,7 @@
5051
* @since 1.0.0
5152
*/
5253
@JsonInclude(Include.NON_NULL)
53-
public class GoogleGenAiChatOptions implements ToolCallingChatOptions {
54+
public class GoogleGenAiChatOptions implements ToolCallingChatOptions, StructuredOutputChatOptions {
5455

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

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

102+
/**
103+
* Optional. Geminie response schema.
104+
*/
105+
private @JsonProperty("responseSchema") String responseSchema;
106+
101107
/**
102108
* Optional. Frequency penalties.
103109
*/
@@ -220,8 +226,8 @@ public static GoogleGenAiChatOptions fromOptions(GoogleGenAiChatOptions fromOpti
220226
options.setModel(fromOptions.getModel());
221227
options.setToolCallbacks(fromOptions.getToolCallbacks());
222228
options.setResponseMimeType(fromOptions.getResponseMimeType());
229+
options.setResponseSchema(fromOptions.getResponseSchema());
223230
options.setToolNames(fromOptions.getToolNames());
224-
options.setResponseMimeType(fromOptions.getResponseMimeType());
225231
options.setGoogleSearchRetrieval(fromOptions.getGoogleSearchRetrieval());
226232
options.setSafetySettings(fromOptions.getSafetySettings());
227233
options.setInternalToolExecutionEnabled(fromOptions.getInternalToolExecutionEnabled());
@@ -318,6 +324,14 @@ public void setResponseMimeType(String mimeType) {
318324
this.responseMimeType = mimeType;
319325
}
320326

327+
public String getResponseSchema() {
328+
return this.responseSchema;
329+
}
330+
331+
public void setResponseSchema(String responseSchema) {
332+
this.responseSchema = responseSchema;
333+
}
334+
321335
@Override
322336
public List<ToolCallback> getToolCallbacks() {
323337
return this.toolCallbacks;
@@ -472,6 +486,18 @@ public void setToolContext(Map<String, Object> toolContext) {
472486
this.toolContext = toolContext;
473487
}
474488

489+
@Override
490+
public String getOutputSchema() {
491+
return this.getResponseSchema();
492+
}
493+
494+
@Override
495+
@JsonIgnore
496+
public void setOutputSchema(String jsonSchemaText) {
497+
this.setResponseSchema(jsonSchemaText);
498+
this.setResponseMimeType("application/json");
499+
}
500+
475501
@Override
476502
public boolean equals(Object o) {
477503
if (this == o) {
@@ -491,6 +517,7 @@ public boolean equals(Object o) {
491517
&& this.thinkingLevel == that.thinkingLevel
492518
&& Objects.equals(this.maxOutputTokens, that.maxOutputTokens) && Objects.equals(this.model, that.model)
493519
&& Objects.equals(this.responseMimeType, that.responseMimeType)
520+
&& Objects.equals(this.responseSchema, that.responseSchema)
494521
&& Objects.equals(this.toolCallbacks, that.toolCallbacks)
495522
&& Objects.equals(this.toolNames, that.toolNames)
496523
&& Objects.equals(this.safetySettings, that.safetySettings)
@@ -501,10 +528,10 @@ public boolean equals(Object o) {
501528
@Override
502529
public int hashCode() {
503530
return Objects.hash(this.stopSequences, this.temperature, this.topP, this.topK, this.candidateCount,
504-
this.frequencyPenalty, this.presencePenalty, this.thinkingBudget, this.includeThoughts,
505-
this.thinkingLevel, this.maxOutputTokens, this.model, this.responseMimeType, this.toolCallbacks,
506-
this.toolNames, this.googleSearchRetrieval, this.safetySettings, this.internalToolExecutionEnabled,
507-
this.toolContext, this.labels);
531+
this.frequencyPenalty, this.presencePenalty, this.includeThoughts, this.thinkingLevel,
532+
this.thinkingBudget, this.maxOutputTokens, this.model, this.responseMimeType, this.responseSchema,
533+
this.toolCallbacks, this.toolNames, this.googleSearchRetrieval, this.safetySettings,
534+
this.internalToolExecutionEnabled, this.toolContext, this.labels);
508535
}
509536

510537
@Override
@@ -591,6 +618,16 @@ public Builder responseMimeType(String mimeType) {
591618
return this;
592619
}
593620

621+
public Builder responseSchema(String responseSchema) {
622+
this.options.setResponseSchema(responseSchema);
623+
return this;
624+
}
625+
626+
public Builder outputSchema(String jsonSchema) {
627+
this.options.setOutputSchema(jsonSchema);
628+
return this;
629+
}
630+
594631
public Builder toolCallbacks(List<ToolCallback> toolCallbacks) {
595632
this.options.toolCallbacks = toolCallbacks;
596633
return this;

0 commit comments

Comments
 (0)