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;
+ }
+
+ }
+
+}