diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java index d089276c37..d8006c908f 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/ChatClient.java @@ -498,6 +498,25 @@ interface ChatClientRequestSpec { */ interface Builder { + /** + * Configure the default factory used to create converters for type-based + * {@link CallResponseSpec#entity(Class)} and + * {@link CallResponseSpec#responseEntity(Class)} calls, including their + * {@link ParameterizedTypeReference} variants. + *

+ * Explicit converter overloads bypass this factory. + * @param factory the structured output converter factory + * @return this builder + * @throws UnsupportedOperationException if this builder implementation does not + * support structured output converter factory configuration + * @since 2.0.1 + */ + default Builder defaultStructuredOutputConverterFactory(StructuredOutputConverterFactory factory) { + Assert.notNull(factory, "factory cannot be null"); + throw new UnsupportedOperationException( + "Structured output converter factory configuration is not supported by " + getClass().getName()); + } + Builder defaultAdvisors(Advisor... advisors); Builder defaultAdvisors(Consumer advisorSpecConsumer); diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java index 6b785ec3e0..c5e0448ea7 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java @@ -95,6 +95,13 @@ public class DefaultChatClient implements ChatClient { private static final ChatClientMessageAggregator CHAT_CLIENT_MESSAGE_AGGREGATOR = new ChatClientMessageAggregator(); + private static final StructuredOutputConverterFactory DEFAULT_STRUCTURED_OUTPUT_CONVERTER_FACTORY = new StructuredOutputConverterFactory() { + @Override + public StructuredOutputConverter create(ParameterizedTypeReference targetType) { + return new BeanOutputConverter<>(targetType); + } + }; + private final DefaultChatClientRequestSpec defaultChatClientRequest; public DefaultChatClient(DefaultChatClientRequestSpec defaultChatClientRequest) { @@ -438,17 +445,28 @@ public static class DefaultCallResponseSpec implements CallResponseSpec { private final ChatClientObservationConvention observationConvention; + private final StructuredOutputConverterFactory structuredOutputConverterFactory; + public DefaultCallResponseSpec(ChatClientRequest chatClientRequest, BaseAdvisorChain advisorChain, ObservationRegistry observationRegistry, ChatClientObservationConvention observationConvention) { + this(chatClientRequest, advisorChain, observationRegistry, observationConvention, + DEFAULT_STRUCTURED_OUTPUT_CONVERTER_FACTORY); + } + + DefaultCallResponseSpec(ChatClientRequest chatClientRequest, BaseAdvisorChain advisorChain, + ObservationRegistry observationRegistry, ChatClientObservationConvention observationConvention, + StructuredOutputConverterFactory structuredOutputConverterFactory) { Assert.notNull(chatClientRequest, "chatClientRequest cannot be null"); Assert.notNull(advisorChain, "advisorChain cannot be null"); Assert.notNull(observationRegistry, "observationRegistry cannot be null"); Assert.notNull(observationConvention, "observationConvention cannot be null"); + Assert.notNull(structuredOutputConverterFactory, "structuredOutputConverterFactory cannot be null"); this.request = chatClientRequest; this.advisorChain = advisorChain; this.observationRegistry = observationRegistry; this.observationConvention = observationConvention; + this.structuredOutputConverterFactory = structuredOutputConverterFactory; } @Override @@ -456,14 +474,14 @@ public ResponseEntity responseEntity(Class type, Consumer entityParamSpecConsumer) { Assert.notNull(type, "type cannot be null"); Assert.notNull(entityParamSpecConsumer, "entityParamSpecConsumer cannot be null"); - var converter = new BeanOutputConverter<>(type); + StructuredOutputConverter converter = createConverter(type); return doResponseEntity(converter, resolveAdvisorChain(entityParamSpecConsumer, converter)); } @Override public ResponseEntity responseEntity(Class type) { Assert.notNull(type, "type cannot be null"); - return doResponseEntity(new BeanOutputConverter<>(type)); + return doResponseEntity(createConverter(type)); } @Override @@ -471,14 +489,14 @@ public ResponseEntity responseEntity(ParameterizedTypeRefer Consumer entityParamSpecConsumer) { Assert.notNull(type, "type cannot be null"); Assert.notNull(entityParamSpecConsumer, "entityParamSpecConsumer cannot be null"); - var converter = new BeanOutputConverter<>(type); + StructuredOutputConverter converter = createConverter(type); return doResponseEntity(converter, resolveAdvisorChain(entityParamSpecConsumer, converter)); } @Override public ResponseEntity responseEntity(ParameterizedTypeReference type) { Assert.notNull(type, "type cannot be null"); - return doResponseEntity(new BeanOutputConverter<>(type)); + return doResponseEntity(createConverter(type)); } @Override @@ -530,22 +548,22 @@ protected ResponseEntity doResponseEntity(StructuredOutputC Consumer entitySpecConsumer) { Assert.notNull(type, "type cannot be null"); Assert.notNull(entitySpecConsumer, "entitySpecConsumer cannot be null"); - var converter = new BeanOutputConverter<>(type); - return doSingleWithBeanOutputConverter(converter, resolveAdvisorChain(entitySpecConsumer, converter)); + StructuredOutputConverter converter = createConverter(type); + return doSingleWithStructuredOutputConverter(converter, resolveAdvisorChain(entitySpecConsumer, converter)); } @Override public @Nullable T entity(Class type, Consumer entitySpecConsumer) { Assert.notNull(type, "type cannot be null"); Assert.notNull(entitySpecConsumer, "entitySpecConsumer cannot be null"); - var converter = new BeanOutputConverter<>(type); - return doSingleWithBeanOutputConverter(converter, resolveAdvisorChain(entitySpecConsumer, converter)); + StructuredOutputConverter converter = createConverter(type); + return doSingleWithStructuredOutputConverter(converter, resolveAdvisorChain(entitySpecConsumer, converter)); } @Override public @Nullable T entity(ParameterizedTypeReference type) { Assert.notNull(type, "type cannot be null"); - return doSingleWithBeanOutputConverter(new BeanOutputConverter<>(type)); + return doSingleWithStructuredOutputConverter(createConverter(type)); } @Override @@ -553,20 +571,30 @@ protected ResponseEntity doResponseEntity(StructuredOutputC Consumer entitySpecConsumer) { Assert.notNull(structuredOutputConverter, "structuredOutputConverter cannot be null"); Assert.notNull(entitySpecConsumer, "entitySpecConsumer cannot be null"); - return doSingleWithBeanOutputConverter(structuredOutputConverter, + return doSingleWithStructuredOutputConverter(structuredOutputConverter, resolveAdvisorChain(entitySpecConsumer, structuredOutputConverter)); } @Override public @Nullable T entity(StructuredOutputConverter structuredOutputConverter) { Assert.notNull(structuredOutputConverter, "structuredOutputConverter cannot be null"); - return doSingleWithBeanOutputConverter(structuredOutputConverter); + return doSingleWithStructuredOutputConverter(structuredOutputConverter); } @Override public @Nullable T entity(Class type) { Assert.notNull(type, "type cannot be null"); - return doSingleWithBeanOutputConverter(new BeanOutputConverter<>(type)); + return doSingleWithStructuredOutputConverter(createConverter(type)); + } + + private StructuredOutputConverter createConverter(Class targetType) { + return createConverter(ParameterizedTypeReference.forType(targetType)); + } + + private StructuredOutputConverter createConverter(ParameterizedTypeReference targetType) { + StructuredOutputConverter converter = this.structuredOutputConverterFactory.create(targetType); + Assert.notNull(converter, "structuredOutputConverterFactory must not return null"); + return converter; } private BaseAdvisorChain resolveAdvisorChain(Consumer consumer, @@ -585,11 +613,11 @@ private BaseAdvisorChain resolveAdvisorChain(Consumer consumer, return this.advisorChain; } - private @Nullable T doSingleWithBeanOutputConverter(StructuredOutputConverter outputConverter) { - return doSingleWithBeanOutputConverter(outputConverter, this.advisorChain); + private @Nullable T doSingleWithStructuredOutputConverter(StructuredOutputConverter outputConverter) { + return doSingleWithStructuredOutputConverter(outputConverter, this.advisorChain); } - private @Nullable T doSingleWithBeanOutputConverter(StructuredOutputConverter outputConverter, + private @Nullable T doSingleWithStructuredOutputConverter(StructuredOutputConverter outputConverter, BaseAdvisorChain advisorChain) { if (StringUtils.hasText(outputConverter.getFormat())) { @@ -800,6 +828,8 @@ public static class DefaultChatClientRequestSpec implements ChatClientRequestSpe private final ToolCallingAdvisor.Builder toolCallingAdvisorBuilder; + private StructuredOutputConverterFactory structuredOutputConverterFactory = DEFAULT_STRUCTURED_OUTPUT_CONVERTER_FACTORY; + /* copy constructor */ DefaultChatClientRequestSpec(DefaultChatClientRequestSpec ccr) { this(ccr.chatModel, ccr.userText, ccr.userParams, ccr.userMetadata, ccr.systemText, ccr.systemParams, @@ -807,6 +837,7 @@ public static class DefaultChatClientRequestSpec implements ChatClientRequestSpe ccr.optionsCustomizer, ccr.advisors, ccr.advisorParams, ccr.observationRegistry, ccr.chatClientObservationConvention, ccr.toolContext, ccr.templateRenderer, ccr.advisorObservationConvention, ccr.toolCallingAdvisorBuilder); + this.structuredOutputConverterFactory = ccr.structuredOutputConverterFactory; } public DefaultChatClientRequestSpec(ChatModel chatModel, @Nullable String userText, @@ -917,6 +948,11 @@ public TemplateRenderer getTemplateRenderer() { return this.templateRenderer; } + void structuredOutputConverterFactory(StructuredOutputConverterFactory factory) { + Assert.notNull(factory, "factory cannot be null"); + this.structuredOutputConverterFactory = factory; + } + /* package */ ChatModel getChatModel() { return this.chatModel; } @@ -937,7 +973,8 @@ public Builder mutate() { .defaultTemplateRenderer(this.templateRenderer) .defaultTools(this.toolCallbacks.toArray(new ToolCallback[0])) .defaultTools((Object[]) this.toolCallbackProviders.toArray(new ToolCallbackProvider[0])) - .defaultToolContext(this.toolContext); + .defaultToolContext(this.toolContext) + .defaultStructuredOutputConverterFactory(this.structuredOutputConverterFactory); if (!CollectionUtils.isEmpty(this.advisors)) { builder.defaultAdvisors(a -> a.advisors(this.advisors).params(this.advisorParams)); @@ -1176,7 +1213,8 @@ public ChatClientRequestSpec templateRenderer(TemplateRenderer templateRenderer) public CallResponseSpec call() { BaseAdvisorChain advisorChain = buildAdvisorChain(); return new DefaultCallResponseSpec(DefaultChatClientUtils.toChatClientRequest(this), advisorChain, - this.observationRegistry, this.chatClientObservationConvention); + this.observationRegistry, this.chatClientObservationConvention, + this.structuredOutputConverterFactory); } @Override diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java index 4496000006..918451ed3f 100644 --- a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClientBuilder.java @@ -119,6 +119,13 @@ public Builder clone() { return this.defaultRequest.mutate(); } + @Override + public Builder defaultStructuredOutputConverterFactory(StructuredOutputConverterFactory factory) { + Assert.notNull(factory, "factory cannot be null"); + this.defaultRequest.structuredOutputConverterFactory(factory); + return this; + } + public Builder defaultAdvisors(Advisor... advisors) { this.defaultRequest.advisors(advisors); return this; diff --git a/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/StructuredOutputConverterFactory.java b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/StructuredOutputConverterFactory.java new file mode 100644 index 0000000000..21f9190b3f --- /dev/null +++ b/spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/StructuredOutputConverterFactory.java @@ -0,0 +1,88 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.client; + +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.converter.ResponseTextCleaner; +import org.springframework.ai.converter.StructuredOutputConverter; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.util.Assert; + +/** + * Factory for creating {@link StructuredOutputConverter} instances for type-based + * {@link ChatClient.CallResponseSpec#entity(Class)} and + * {@link ChatClient.CallResponseSpec#responseEntity(Class)} calls. + *

+ * Implementations must return a non-null converter that supports the supplied target + * type. A factory is shared by the {@link ChatClient} instances created or derived from a + * configured builder and must therefore be safe to invoke concurrently. Any converter + * instance shared between invocations must also be safe for concurrent use. + *

+ * Explicit converter overloads bypass this factory. + * + * @author Jaehyeon Park + * @since 2.0.1 + */ +public interface StructuredOutputConverterFactory { + + /** + * Create a converter for the given target type. + * @param targetType the target type + * @return a non-null structured output converter + * @since 2.0.1 + */ + StructuredOutputConverter create(ParameterizedTypeReference targetType); + + /** + * Create a factory that uses {@link BeanOutputConverter} with the given JSON mapper. + * @param jsonMapper the JSON mapper + * @return a structured output converter factory + * @since 2.0.1 + */ + static StructuredOutputConverterFactory beanOutputConverter(JsonMapper jsonMapper) { + Assert.notNull(jsonMapper, "jsonMapper cannot be null"); + return new StructuredOutputConverterFactory() { + @Override + public StructuredOutputConverter create(ParameterizedTypeReference targetType) { + return new BeanOutputConverter<>(targetType, jsonMapper); + } + }; + } + + /** + * Create a factory that uses {@link BeanOutputConverter} with the given JSON mapper + * and response text cleaner. + * @param jsonMapper the JSON mapper + * @param textCleaner the response text cleaner + * @return a structured output converter factory + * @since 2.0.1 + */ + static StructuredOutputConverterFactory beanOutputConverter(JsonMapper jsonMapper, + ResponseTextCleaner textCleaner) { + Assert.notNull(jsonMapper, "jsonMapper cannot be null"); + Assert.notNull(textCleaner, "textCleaner cannot be null"); + return new StructuredOutputConverterFactory() { + @Override + public StructuredOutputConverter create(ParameterizedTypeReference targetType) { + return new BeanOutputConverter<>(targetType, jsonMapper, textCleaner); + } + }; + } + +} diff --git a/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/StructuredOutputConverterFactoryTests.java b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/StructuredOutputConverterFactoryTests.java new file mode 100644 index 0000000000..296ce4e9ef --- /dev/null +++ b/spring-ai-client-chat/src/test/java/org/springframework/ai/chat/client/StructuredOutputConverterFactoryTests.java @@ -0,0 +1,421 @@ +/* + * Copyright 2023-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.client; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.ai.converter.StructuredOutputConverter; +import org.springframework.ai.model.tool.StructuredOutputChatOptions; +import org.springframework.core.ParameterizedTypeReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link StructuredOutputConverterFactory} integration with + * {@link ChatClient}. + * + * @author Jaehyeon Park + */ +@ExtendWith(MockitoExtension.class) +class StructuredOutputConverterFactoryTests { + + private static final String CUSTOM_NATIVE_SCHEMA = "{\"type\":\"object\"}"; + + private static final String CUSTOM_VALIDATION_SCHEMA = """ + { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 2 + } + }, + "required": ["value"] + } + """; + + @Mock + private ChatModel chatModel; + + @Test + void defaultFactoryPreservesBeanOutputConverterBehavior() { + stubResponse("{\"value\":\"default\"}"); + + TestEntity entity = ChatClient.builder(this.chatModel).build().prompt().call().entity(TestEntity.class); + + assertThat(entity).isEqualTo(new TestEntity("default")); + } + + @Test + void defaultFactoryPreservesParameterizedBeanOutputConverterBehavior() { + stubResponse("[{\"value\":\"default\"}]"); + var type = new ParameterizedTypeReference>() { + }; + + List entity = ChatClient.builder(this.chatModel).build().prompt().call().entity(type); + + assertThat(entity).containsExactly(new TestEntity("default")); + } + + @Test + void classEntityPassesParameterizedTypeReferenceToFactory() { + stubResponse("{\"value\":\"class\"}"); + var factory = new RecordingFactory(); + + TestEntity entity = client(factory).prompt().call().entity(TestEntity.class); + + assertThat(factory.targetTypes).extracting(ParameterizedTypeReference::getType) + .containsExactly(TestEntity.class); + assertThat(entity).isEqualTo(new TestEntity("class")); + } + + @Test + void parameterizedEntityPreservesCompleteGenericType() { + stubResponse("[{\"value\":\"parameterized\"}]"); + var factory = new RecordingFactory(); + var type = new ParameterizedTypeReference>() { + }; + + List entity = client(factory).prompt().call().entity(type); + + assertThat(factory.targetTypes).extracting(ParameterizedTypeReference::getType).containsExactly(type.getType()); + assertThat(entity).containsExactly(new TestEntity("parameterized")); + } + + @Test + void allTypeBasedEntityAndResponseEntityOverloadsUseFactory() { + stubResponse("{\"value\":\"class\"}"); + var factory = new RecordingFactory(); + var type = new ParameterizedTypeReference() { + }; + ChatClient chatClient = client(factory); + + chatClient.prompt().call().entity(TestEntity.class); + chatClient.prompt().call().entity(type); + chatClient.prompt().call().responseEntity(TestEntity.class); + chatClient.prompt().call().responseEntity(type); + + assertThat(factory.targetTypes).extracting(ParameterizedTypeReference::getType) + .containsExactly(TestEntity.class, type.getType(), TestEntity.class, type.getType()); + } + + @Test + void entityParamSpecVariantsUseFactory() { + stubResponse("{\"value\":\"class\"}"); + var factory = new RecordingFactory(); + var type = new ParameterizedTypeReference() { + }; + ChatClient chatClient = client(factory); + + chatClient.prompt().call().entity(TestEntity.class, spec -> { + }); + chatClient.prompt().call().entity(type, spec -> { + }); + chatClient.prompt().call().responseEntity(TestEntity.class, spec -> { + }); + chatClient.prompt().call().responseEntity(type, spec -> { + }); + + assertThat(factory.targetTypes).extracting(ParameterizedTypeReference::getType) + .containsExactly(TestEntity.class, type.getType(), TestEntity.class, type.getType()); + } + + @Test + void explicitConvertersBypassFactory() { + stubResponse("ignored"); + var expected = new TestEntity("explicit"); + var converter = new FixedConverter<>(expected); + ChatClient chatClient = client(new FailingFactory()); + + assertThat(chatClient.prompt().call().entity(converter)).isSameAs(expected); + assertThat(chatClient.prompt().call().responseEntity(converter).getEntity()).isSameAs(expected); + assertThat(chatClient.prompt().call().entity(converter, spec -> { + })).isSameAs(expected); + assertThat(chatClient.prompt().call().responseEntity(converter, spec -> { + }).getEntity()).isSameAs(expected); + } + + @Test + void plainResponsesBypassFactory() { + stubResponse("plain"); + ChatClient chatClient = client(new FailingFactory()); + + assertThat(chatClient.prompt().call().content()).isEqualTo("plain"); + assertThat(chatClient.prompt().call().chatResponse()).isNotNull(); + assertThat(chatClient.prompt().call().chatClientResponse()).isNotNull(); + } + + @Test + void configuredFactorySurvivesBuilderCloneMutationAndRequestCopy() { + stubResponse("{\"value\":\"copied\"}"); + var factory = new RecordingFactory(); + ChatClient.Builder builder = ChatClient.builder(this.chatModel) + .defaultStructuredOutputConverterFactory(factory); + ChatClient client = builder.build(); + + builder.clone().build().prompt().call().entity(TestEntity.class); + client.mutate().build().prompt().call().entity(TestEntity.class); + client.prompt().call().entity(TestEntity.class); + + assertThat(factory.targetTypes).hasSize(3); + } + + @Test + void replacingFactoryOnMutatedBuilderDoesNotAffectOriginalClient() { + stubResponse("ignored"); + ChatClient original = client(new SubstitutingFactory("{\"value\":\"original\"}")); + ChatClient mutated = original.mutate() + .defaultStructuredOutputConverterFactory(new SubstitutingFactory("{\"value\":\"replacement\"}")) + .build(); + + assertThat(original.prompt().call().entity(TestEntity.class)).isEqualTo(new TestEntity("original")); + assertThat(mutated.prompt().call().entity(TestEntity.class)).isEqualTo(new TestEntity("replacement")); + } + + @Test + void providerStructuredOutputUsesFactoryConverterSchema() { + stubResponse("{\"value\":\"native\"}"); + when(this.chatModel.getOptions()).thenReturn(StructuredOutputChatOptions.builder().build()); + var promptCaptor = ArgumentCaptor.forClass(Prompt.class); + + client(new RecordingFactory(CUSTOM_NATIVE_SCHEMA)).prompt() + .call() + .entity(TestEntity.class, ChatClient.EntityParamSpec::useProviderStructuredOutput); + + verify(this.chatModel).call(promptCaptor.capture()); + var options = (StructuredOutputChatOptions) promptCaptor.getValue().getOptions(); + assertThat(options.getOutputSchema()).isEqualTo(CUSTOM_NATIVE_SCHEMA); + } + + @Test + void schemaValidationUsesFactoryConverterSchema() { + stubResponses("{\"value\":\"x\"}", "{\"value\":\"validated\"}"); + + TestEntity entity = client(new RecordingFactory(CUSTOM_VALIDATION_SCHEMA)).prompt() + .call() + .entity(TestEntity.class, ChatClient.EntityParamSpec::validateSchema); + + assertThat(entity).isEqualTo(new TestEntity("validated")); + verify(this.chatModel, times(2)).call(any(Prompt.class)); + } + + @Test + void unsupportedBuilderFailsExplicitlyInsteadOfIgnoringFactory() { + ChatClient.Builder builder = mock(ChatClient.Builder.class, Answers.CALLS_REAL_METHODS); + StructuredOutputConverterFactory factory = StructuredOutputConverterFactory + .beanOutputConverter(JsonMapper.builder().build()); + + assertThatThrownBy(() -> builder.defaultStructuredOutputConverterFactory(factory)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Structured output converter factory configuration is not supported"); + assertThatThrownBy(() -> builder.defaultStructuredOutputConverterFactory(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("factory cannot be null"); + } + + @Test + void nullFactoryAndNullFactoryResultAreRejected() { + assertThatThrownBy(() -> ChatClient.builder(this.chatModel).defaultStructuredOutputConverterFactory(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("factory cannot be null"); + + when(this.chatModel.getOptions()).thenReturn(ChatOptions.builder().build()); + assertThatThrownBy(() -> client(new NullFactory()).prompt().call().entity(TestEntity.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("structuredOutputConverterFactory must not return null"); + } + + @Test + void staticBeanOutputConverterFactoryUsesCustomMapperAndCleaner() { + stubResponse("not json"); + JsonMapper mapper = JsonMapper.builder().propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE).build(); + StructuredOutputConverterFactory factory = StructuredOutputConverterFactory.beanOutputConverter(mapper, + text -> "{\"camel_value\":\"cleaned\"}"); + + CamelCaseEntity entity = client(factory).prompt().call().entity(CamelCaseEntity.class); + + assertThat(entity).isEqualTo(new CamelCaseEntity("cleaned")); + } + + @Test + void staticBeanOutputConverterFactoryUsesCustomMapper() { + stubResponse("{\"camel_value\":\"mapped\"}"); + JsonMapper mapper = JsonMapper.builder().propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE).build(); + StructuredOutputConverterFactory factory = StructuredOutputConverterFactory.beanOutputConverter(mapper); + + CamelCaseEntity entity = client(factory).prompt().call().entity(CamelCaseEntity.class); + + assertThat(entity).isEqualTo(new CamelCaseEntity("mapped")); + } + + @Test + void staticBeanOutputConverterFactoryRejectsNullArguments() { + JsonMapper mapper = JsonMapper.builder().build(); + + assertThatThrownBy(() -> StructuredOutputConverterFactory.beanOutputConverter(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("jsonMapper cannot be null"); + assertThatThrownBy(() -> StructuredOutputConverterFactory.beanOutputConverter(mapper, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("textCleaner cannot be null"); + } + + private ChatClient client(StructuredOutputConverterFactory factory) { + return ChatClient.builder(this.chatModel).defaultStructuredOutputConverterFactory(factory).build(); + } + + private void stubResponse(String content) { + when(this.chatModel.getOptions()).thenReturn(ChatOptions.builder().build()); + when(this.chatModel.call(any(Prompt.class))) + .thenReturn(new ChatResponse(List.of(new Generation(new AssistantMessage(content))))); + } + + private void stubResponses(String first, String second) { + when(this.chatModel.getOptions()).thenReturn(ChatOptions.builder().build()); + when(this.chatModel.call(any(Prompt.class))).thenReturn(response(first), response(second)); + } + + private ChatResponse response(String content) { + return new ChatResponse(List.of(new Generation(new AssistantMessage(content)))); + } + + private static StructuredOutputConverter converter(ParameterizedTypeReference targetType, + String replacementSource, String jsonSchema) { + BeanOutputConverter delegate = new BeanOutputConverter<>(targetType); + return new StructuredOutputConverter<>() { + @Override + public T convert(String source) { + return delegate.convert(replacementSource != null ? replacementSource : source); + } + + @Override + public String getFormat() { + return delegate.getFormat(); + } + + @Override + public String getJsonSchema() { + return jsonSchema; + } + }; + } + + private record TestEntity(String value) { + } + + private record CamelCaseEntity(String camelValue) { + } + + private static final class FixedConverter implements StructuredOutputConverter { + + private final T value; + + private FixedConverter(T value) { + this.value = value; + } + + @Override + public T convert(String source) { + return this.value; + } + + @Override + public String getFormat() { + return "test format"; + } + + } + + private static final class RecordingFactory implements StructuredOutputConverterFactory { + + private final String jsonSchema; + + private final List> targetTypes = new ArrayList<>(); + + private RecordingFactory() { + this(StructuredOutputConverter.NO_JSON_SCHEMA); + } + + private RecordingFactory(String jsonSchema) { + this.jsonSchema = jsonSchema; + } + + @Override + public StructuredOutputConverter create(ParameterizedTypeReference targetType) { + this.targetTypes.add(targetType); + return converter(targetType, null, this.jsonSchema); + } + + } + + private static final class SubstitutingFactory implements StructuredOutputConverterFactory { + + private final String source; + + private SubstitutingFactory(String source) { + this.source = source; + } + + @Override + public StructuredOutputConverter create(ParameterizedTypeReference targetType) { + return converter(targetType, this.source, StructuredOutputConverter.NO_JSON_SCHEMA); + } + + } + + private static final class FailingFactory implements StructuredOutputConverterFactory { + + @Override + public StructuredOutputConverter create(ParameterizedTypeReference targetType) { + throw new AssertionError("Factory should not be called"); + } + + } + + private static final class NullFactory implements StructuredOutputConverterFactory { + + @Override + public StructuredOutputConverter create(ParameterizedTypeReference targetType) { + return null; + } + + } + +}