Skip to content

Commit 569ac6f

Browse files
committed
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 <[email protected]>
1 parent ed4e759 commit 569ac6f

File tree

4 files changed

+203
-19
lines changed

4 files changed

+203
-19
lines changed

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;

models/spring-ai-google-genai/src/test/java/org/springframework/ai/google/genai/GoogleGenAiChatModelIT.java

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.Arrays;
2121
import java.util.List;
2222
import java.util.Map;
23+
import java.util.function.Function;
2324
import java.util.stream.Collectors;
2425
import java.util.stream.Stream;
2526

@@ -31,12 +32,14 @@
3132
import org.slf4j.Logger;
3233
import org.slf4j.LoggerFactory;
3334

35+
import org.springframework.ai.chat.client.AdvisorParams;
3436
import org.springframework.ai.chat.client.ChatClient;
3537
import org.springframework.ai.chat.messages.AssistantMessage;
3638
import org.springframework.ai.chat.messages.Message;
3739
import org.springframework.ai.chat.messages.UserMessage;
3840
import org.springframework.ai.chat.model.ChatResponse;
3941
import org.springframework.ai.chat.model.Generation;
42+
import org.springframework.ai.chat.prompt.ChatOptions;
4043
import org.springframework.ai.chat.prompt.Prompt;
4144
import org.springframework.ai.chat.prompt.PromptTemplate;
4245
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
@@ -53,6 +56,7 @@
5356
import org.springframework.boot.SpringBootConfiguration;
5457
import org.springframework.boot.test.context.SpringBootTest;
5558
import org.springframework.context.annotation.Bean;
59+
import org.springframework.core.ParameterizedTypeReference;
5660
import org.springframework.core.convert.support.DefaultConversionService;
5761
import org.springframework.core.io.ClassPathResource;
5862
import org.springframework.core.io.Resource;
@@ -206,6 +210,98 @@ void beanOutputConverterRecords() {
206210
assertThat(actorsFilms.movies()).hasSize(5);
207211
}
208212

213+
@Test
214+
void beanOutputConverterRecordsWithResponseSchema() {
215+
// Use the Google GenAI API to set the response schema
216+
beanOutputConverterRecordsWithStructuredOutput(jsonSchema -> GoogleGenAiChatOptions.builder()
217+
.responseSchema(jsonSchema)
218+
.responseMimeType("application/json")
219+
.build());
220+
}
221+
222+
@Test
223+
void beanOutputConverterRecordsWithOutputSchema() {
224+
// Use the unified Spring AI API (StructuredOutputChatOptions) to set the output
225+
// schema.
226+
beanOutputConverterRecordsWithStructuredOutput(
227+
jsonSchema -> GoogleGenAiChatOptions.builder().outputSchema(jsonSchema).build());
228+
}
229+
230+
private void beanOutputConverterRecordsWithStructuredOutput(Function<String, ChatOptions> chatOptionsProvider) {
231+
232+
BeanOutputConverter<ActorsFilmsRecord> outputConvert = new BeanOutputConverter<>(ActorsFilmsRecord.class);
233+
234+
String schema = outputConvert.getJsonSchema();
235+
236+
Prompt prompt = Prompt.builder()
237+
.content("Generate the filmography of 5 movies for Tom Hanks.")
238+
.chatOptions(chatOptionsProvider.apply(schema))
239+
.build();
240+
241+
Generation generation = this.chatModel.call(prompt).getResult();
242+
243+
ActorsFilmsRecord actorsFilms = outputConvert.convert(generation.getOutput().getText());
244+
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
245+
assertThat(actorsFilms.movies()).hasSize(5);
246+
}
247+
248+
@Test
249+
void chatClientBeanOutputConverterRecords() {
250+
251+
var chatClient = ChatClient.builder(this.chatModel).build();
252+
253+
ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.")
254+
.call()
255+
.entity(ActorsFilmsRecord.class);
256+
257+
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
258+
assertThat(actorsFilms.movies()).hasSize(5);
259+
}
260+
261+
@Test
262+
void chatClientBeanOutputConverterRecordsNative() {
263+
264+
var chatClient = ChatClient.builder(this.chatModel).build();
265+
266+
ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.")
267+
// forces native structured output handling
268+
.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
269+
.call()
270+
.entity(ActorsFilmsRecord.class);
271+
272+
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
273+
assertThat(actorsFilms.movies()).hasSize(5);
274+
}
275+
276+
@Test
277+
void listOutputConverterBean() {
278+
279+
// @formatter:off
280+
List<ActorsFilmsRecord> actorsFilms = ChatClient.create(this.chatModel).prompt()
281+
.user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
282+
.call()
283+
.entity(new ParameterizedTypeReference<>() {
284+
});
285+
// @formatter:on
286+
287+
assertThat(actorsFilms).hasSize(2);
288+
}
289+
290+
@Test
291+
void listOutputConverterBeanNative() {
292+
293+
// @formatter:off
294+
List<ActorsFilmsRecord> actorsFilms = ChatClient.create(this.chatModel).prompt()
295+
.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
296+
.user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
297+
.call()
298+
.entity(new ParameterizedTypeReference<>() {
299+
});
300+
// @formatter:on
301+
302+
assertThat(actorsFilms).hasSize(2);
303+
}
304+
209305
@Test
210306
void textStream() {
211307

models/spring-ai-vertex-ai-gemini/src/test/java/org/springframework/ai/vertexai/gemini/VertexAiGeminiChatModelIT.java

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
import java.util.Arrays;
2121
import java.util.List;
2222
import java.util.Map;
23+
import java.util.function.Function;
2324
import java.util.stream.Collectors;
2425
import java.util.stream.Stream;
2526

27+
import com.fasterxml.jackson.databind.node.ObjectNode;
2628
import com.google.cloud.vertexai.Transport;
2729
import com.google.cloud.vertexai.VertexAI;
2830
import io.micrometer.observation.ObservationRegistry;
@@ -37,6 +39,7 @@
3739
import org.springframework.ai.chat.messages.UserMessage;
3840
import org.springframework.ai.chat.model.ChatResponse;
3941
import org.springframework.ai.chat.model.Generation;
42+
import org.springframework.ai.chat.prompt.ChatOptions;
4043
import org.springframework.ai.chat.prompt.Prompt;
4144
import org.springframework.ai.chat.prompt.PromptTemplate;
4245
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
@@ -46,9 +49,11 @@
4649
import org.springframework.ai.converter.MapOutputConverter;
4750
import org.springframework.ai.model.tool.ToolCallingManager;
4851
import org.springframework.ai.tool.annotation.Tool;
52+
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
4953
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel.ChatModel;
5054
import org.springframework.ai.vertexai.gemini.api.VertexAiGeminiApi;
5155
import org.springframework.ai.vertexai.gemini.common.VertexAiGeminiSafetySetting;
56+
import org.springframework.ai.vertexai.gemini.schema.JsonSchemaConverter;
5257
import org.springframework.beans.factory.annotation.Autowired;
5358
import org.springframework.beans.factory.annotation.Value;
5459
import org.springframework.boot.SpringBootConfiguration;
@@ -211,6 +216,48 @@ void beanOutputConverterRecords() {
211216
assertThat(actorsFilms.movies()).hasSize(5);
212217
}
213218

219+
@Test
220+
void beanOutputConverterRecordsWithResponseSchema() {
221+
222+
// Use the Google GenAI API to set the response schema
223+
beanOutputConverterRecordsWithStructuredOutput(jsonSchemaText -> {
224+
ObjectNode jsonSchema = JsonSchemaConverter.fromJson(jsonSchemaText);
225+
ObjectNode openApiSchema = JsonSchemaConverter.convertToOpenApiSchema(jsonSchema);
226+
JsonSchemaGenerator.convertTypeValuesToUpperCase(openApiSchema);
227+
228+
return VertexAiGeminiChatOptions.builder()
229+
.responseSchema(openApiSchema.toString())
230+
.responseMimeType("application/json")
231+
.build();
232+
});
233+
}
234+
235+
@Test
236+
void beanOutputConverterRecordsWithOutputSchema() {
237+
// Use the unified Spring AI API (StructuredOutputChatOptions) to set the output
238+
// schema.
239+
beanOutputConverterRecordsWithStructuredOutput(
240+
jsonSchema -> VertexAiGeminiChatOptions.builder().outputSchema(jsonSchema).build());
241+
}
242+
243+
private void beanOutputConverterRecordsWithStructuredOutput(Function<String, ChatOptions> chatOptionsProvider) {
244+
245+
BeanOutputConverter<ActorsFilmsRecord> outputConvert = new BeanOutputConverter<>(ActorsFilmsRecord.class);
246+
247+
String schema = outputConvert.getJsonSchema();
248+
249+
Prompt prompt = Prompt.builder()
250+
.content("Generate the filmography of 5 movies for Tom Hanks.")
251+
.chatOptions(chatOptionsProvider.apply(schema))
252+
.build();
253+
254+
Generation generation = this.chatModel.call(prompt).getResult();
255+
256+
ActorsFilmsRecord actorsFilms = outputConvert.convert(generation.getOutput().getText());
257+
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
258+
assertThat(actorsFilms.movies()).hasSize(5);
259+
}
260+
214261
@Test
215262
void chatClientBeanOutputConverterRecords() {
216263

@@ -224,6 +271,20 @@ void chatClientBeanOutputConverterRecords() {
224271
assertThat(actorsFilms.movies()).hasSize(5);
225272
}
226273

274+
@Test
275+
void chatClientBeanOutputConverterRecordsNative() {
276+
277+
var chatClient = ChatClient.builder(this.chatModel).build();
278+
279+
ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.")
280+
.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
281+
.call()
282+
.entity(ActorsFilmsRecord.class);
283+
284+
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
285+
assertThat(actorsFilms.movies()).hasSize(5);
286+
}
287+
227288
@Test
228289
void listOutputConverterBean() {
229290

@@ -239,7 +300,7 @@ void listOutputConverterBean() {
239300
}
240301

241302
@Test
242-
void listOutputConverterBean2() {
303+
void listOutputConverterBeanNative() {
243304

244305
// @formatter:off
245306
List<ActorsFilmsRecord> actorsFilms = ChatClient.create(this.chatModel).prompt()
@@ -253,20 +314,6 @@ void listOutputConverterBean2() {
253314
assertThat(actorsFilms).hasSize(2);
254315
}
255316

256-
@Test
257-
void chatClientBeanOutputConverterRecords2() {
258-
259-
var chatClient = ChatClient.builder(this.chatModel).build();
260-
261-
ActorsFilmsRecord actorsFilms = chatClient.prompt("Generate the filmography of 5 movies for Tom Hanks.")
262-
.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
263-
.call()
264-
.entity(ActorsFilmsRecord.class);
265-
266-
assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks");
267-
assertThat(actorsFilms.movies()).hasSize(5);
268-
}
269-
270317
@Test
271318
void textStream() {
272319

0 commit comments

Comments
 (0)