diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java index 759065f38..fffc38f7b 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatClient.java @@ -25,6 +25,7 @@ import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import org.springframework.ai.chat.ChatClient; +import org.springframework.ai.chat.ChatOptions; import org.springframework.ai.chat.ChatResponse; import org.springframework.ai.chat.Generation; import org.springframework.ai.chat.StreamingChatClient; @@ -166,8 +167,10 @@ public class OpenAiChatClient implements ChatClient, StreamingChatClient { } if (prompt.getOptions() != null) { - if (prompt.getOptions() instanceof OpenAiChatOptions runtimeOptions) { - request = ModelOptionsUtils.merge(runtimeOptions, request, ChatCompletionRequest.class); + if (prompt.getOptions() instanceof ChatOptions runtimeOptions) { + OpenAiChatOptions updatedRuntimeOptions = ModelOptionsUtils.copyToTarget(runtimeOptions, + ChatOptions.class, OpenAiChatOptions.class); + request = ModelOptionsUtils.merge(updatedRuntimeOptions, request, ChatCompletionRequest.class); } else { throw new IllegalArgumentException("Prompt options are not of type ChatCompletionRequest:" diff --git a/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java b/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java index f6cd90531..3e27fc4ca 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/chat/ChatOptions.java @@ -19,12 +19,10 @@ package org.springframework.ai.chat; import org.springframework.ai.model.ModelOptions; /** - * portable options + * The ChatOptions represent the common options, portable across different chat models. */ public interface ChatOptions extends ModelOptions { - // determine portable optionsb - Float getTemperature(); void setTemperature(Float temperature); diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java b/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java index 96ddc1e3f..185f9ea09 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java @@ -16,8 +16,10 @@ package org.springframework.ai.model; +import java.beans.PropertyDescriptor; import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -30,25 +32,37 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.BeanWrapperImpl; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; /** * Utility class for manipulating {@link ModelOptions} objects. * * @author Christian Tzolov + * @since 0.8.0 */ public final class ModelOptionsUtils { private final static ObjectMapper OBJECT_MAPPER = new ObjectMapper() .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + private final static List BEAN_MERGE_FIELD_EXCISIONS = List.of("class"); + + private static ConcurrentHashMap, List> REQUEST_FIELD_NAMES_PER_CLASS = new ConcurrentHashMap, List>(); + private ModelOptionsUtils() { } /** * Merges the source object into the target object and returns an object represented - * by the given class. The source null values are ignored. + * by the given class. The JSON property names are used to match the fields to merge. + * The source non-null values override the target values with the same field name. The + * source null values are ignored. If the acceptedFieldNames is not empty, only the + * fields with the given names are merged and returned. If the acceptedFieldNames is + * empty, use the {@code @JsonProperty} names, inferred from the provided clazz. * @param they type of the class to return. * @param source the source object to merge. * @param target the target object to merge into. @@ -62,8 +76,12 @@ public final class ModelOptionsUtils { ? REQUEST_FIELD_NAMES_PER_CLASS.computeIfAbsent(clazz, ModelOptionsUtils::getJsonPropertyValues) : acceptedFieldNames; - Map sourceMap = objectToMap(source); - Map targetMap = objectToMap(target); + if (CollectionUtils.isEmpty(requestFieldNames)) { + throw new IllegalArgumentException("No @JsonProperty fields found in the " + clazz.getName()); + } + + Map sourceMap = ModelOptionsUtils.objectToMap(source); + Map targetMap = ModelOptionsUtils.objectToMap(target); targetMap.putAll(sourceMap.entrySet() .stream() @@ -77,14 +95,15 @@ public final class ModelOptionsUtils { .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); } - return mapToClass(targetMap, clazz); + return ModelOptionsUtils.mapToClass(targetMap, clazz); } - private static ConcurrentHashMap, List> REQUEST_FIELD_NAMES_PER_CLASS = new ConcurrentHashMap, List>(); - /** * Merges the source object into the target object and returns an object represented - * by the given class. The source null values are ignored. + * by the given class. The JSON property names are used to match the fields to merge. + * The source non-null values override the target values with the same field name. The + * source null values are ignored. Returns the only field names that match the + * {@code @JsonProperty} names, inferred from the provided clazz. * @param they type of the class to return. * @param source the source object to merge. * @param target the target object to merge into. @@ -92,7 +111,7 @@ public final class ModelOptionsUtils { * @return the merged object represented by the given class. */ public static T merge(Object source, Object target, Class clazz) { - return merge(source, target, clazz, null); + return ModelOptionsUtils.merge(source, target, clazz, null); } /** @@ -132,7 +151,7 @@ public final class ModelOptionsUtils { } /** - * Returns the list of values of the {@link JsonProperty} annotations. + * Returns the list of name values of the {@link JsonProperty} annotations. * @param clazz the class that contains fields annotated with {@link JsonProperty}. * @return the list of values of the {@link JsonProperty} annotations. */ @@ -148,4 +167,95 @@ public final class ModelOptionsUtils { return values; } + /** + * Returns a new instance of the targetBeanClazz that copies the bean values from the + * sourceBean instance. + * @param sourceBean the source bean to copy the values from. + * @param sourceInterfaceClazz the source interface class. Only the fields with the + * same name as the interface methods are copied. This allow the source object to be a + * subclass of the source interface with additional, non-interface fields. + * @param targetBeanClazz the target class, a subclass of the ChatOptions, to convert + * into. + * @param the target class type. + * @return a new instance of the targetBeanClazz with the values from the sourceBean + * instance. + */ + public static T copyToTarget(S sourceBean, Class sourceInterfaceClazz, + Class targetBeanClazz) { + + Assert.notNull(sourceInterfaceClazz, "SourceOptionsClazz must not be null"); + Assert.notNull(targetBeanClazz, "TargetOptionsClazz must not be null"); + + if (sourceBean == null) { + return null; + } + + if (sourceBean.getClass().isAssignableFrom(targetBeanClazz)) { + return (T) sourceBean; + } + + try { + T targetOptions = targetBeanClazz.getConstructor().newInstance(); + + ModelOptionsUtils.mergeBeans(sourceBean, targetOptions, sourceInterfaceClazz, true); + + return targetOptions; + } + catch (Exception e) { + throw new RuntimeException( + "Failed to convert the " + sourceInterfaceClazz.getName() + " into " + targetBeanClazz.getName(), + e); + } + } + + /** + * Merges the source object into the target object. The source null values are + * ignored. Only objects with Getter and Setter methods are supported. + * @param the type of the source and target object. + * @param source the source object to merge. + * @param target the target object to merge into. + * @param sourceInterfaceClazz the source interface class. Only the fields with the + * same name as the interface methods are merged. This allow the source object to be a + * subclass of the source interface with additional, non-interface fields. + * @param overrideNonNullTargetValues if true, the source non-null values override the + * target values with the same field name. If false, the source non-null values are + * ignored. + * @return the merged target object. + */ + public static T mergeBeans(S source, T target, Class sourceInterfaceClazz, + boolean overrideNonNullTargetValues) { + Assert.notNull(source, "Source object must not be null"); + Assert.notNull(target, "Target object must not be null"); + + BeanWrapper sourceBeanWrap = new BeanWrapperImpl(source); + BeanWrapper targetBeanWrap = new BeanWrapperImpl(target); + + List interfaceNames = Arrays.stream(sourceInterfaceClazz.getMethods()).map(m -> m.getName()).toList(); + + for (PropertyDescriptor descriptor : sourceBeanWrap.getPropertyDescriptors()) { + + if (!BEAN_MERGE_FIELD_EXCISIONS.contains(descriptor.getName()) + && interfaceNames.contains(toGetName(descriptor.getName()))) { + + String propertyName = descriptor.getName(); + Object value = sourceBeanWrap.getPropertyValue(propertyName); + + // Copy value to the target object + if (value != null) { + var targetValue = targetBeanWrap.getPropertyValue(propertyName); + + if (targetValue == null || overrideNonNullTargetValues) { + targetBeanWrap.setPropertyValue(propertyName, value); + } + } + } + } + + return target; + } + + private static String toGetName(String name) { + return "get" + name.substring(0, 1).toUpperCase() + name.substring(1); + } + } diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java b/spring-ai-core/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java new file mode 100644 index 000000000..b6e4c6803 --- /dev/null +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java @@ -0,0 +1,230 @@ +/* + * Copyright 2024-2024 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.model; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Christian Tzolov + */ +public class ModelOptionsUtilsTests { + + public static interface TestPortableOptions extends ModelOptions { + + String getName(); + + void setName(String name); + + Integer getAge(); + + void setAge(Integer age); + + } + + public static class TestPortableOptionsImpl implements TestPortableOptions { + + private String name; + + private Integer age; + + // Non interface fields + private String nonInterfaceField; + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public Integer getAge() { + return age; + } + + @Override + public void setAge(Integer age) { + this.age = age; + } + + public String getNonInterfaceField() { + return nonInterfaceField; + } + + public void setNonInterfaceField(String nonInterfaceField) { + this.nonInterfaceField = nonInterfaceField; + } + + } + + public static class TestSpecificOptions implements TestPortableOptions { + + @JsonProperty("specificField") + private String specificField; + + @JsonProperty("name") + private String name; + + @JsonProperty("age") + private Integer age; + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public Integer getAge() { + return age; + } + + @Override + public void setAge(Integer age) { + this.age = age; + } + + public String getSpecificField() { + return specificField; + } + + public void setSpecificField(String modelSpecificField) { + this.specificField = modelSpecificField; + } + + @Override + public String toString() { + return "TestModelSpecificOptions{" + "specificField='" + specificField + '\'' + ", name='" + name + '\'' + + ", age=" + age + '}'; + } + + } + + @Test + public void merge() { + TestPortableOptionsImpl portableOptions = new TestPortableOptionsImpl(); + portableOptions.setName("John"); + portableOptions.setAge(30); + portableOptions.setNonInterfaceField("NonInterfaceField"); + + TestSpecificOptions specificOptions = new TestSpecificOptions(); + specificOptions.setName("Mike"); + specificOptions.setSpecificField("SpecificField"); + + assertThatThrownBy(() -> { + ModelOptionsUtils.merge(portableOptions, specificOptions, TestPortableOptionsImpl.class); + }).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("No @JsonProperty fields found in the "); + + var specificOptions2 = ModelOptionsUtils.merge(portableOptions, specificOptions, TestSpecificOptions.class); + + assertThat(specificOptions2.getAge()).isEqualTo(30); + assertThat(specificOptions2.getName()).isEqualTo("John"); // !!! Overridden by the + // portableOptions + assertThat(specificOptions2.getSpecificField()).isEqualTo("SpecificField"); + } + + @Test + public void objectToMap() { + TestPortableOptionsImpl portableOptions = new TestPortableOptionsImpl(); + portableOptions.setName("John"); + portableOptions.setAge(30); + portableOptions.setNonInterfaceField("NonInterfaceField"); + + Map map = ModelOptionsUtils.objectToMap(portableOptions); + + assertThat(map).containsEntry("name", "John"); + assertThat(map).containsEntry("age", 30); + assertThat(map).containsEntry("nonInterfaceField", "NonInterfaceField"); + } + + @Test + public void mapToClass() { + TestPortableOptionsImpl portableOptions = ModelOptionsUtils.mapToClass( + Map.of("name", "John", "age", 30, "nonInterfaceField", "NonInterfaceField"), + TestPortableOptionsImpl.class); + + assertThat(portableOptions.getName()).isEqualTo("John"); + assertThat(portableOptions.getAge()).isEqualTo(30); + assertThat(portableOptions.getNonInterfaceField()).isEqualTo("NonInterfaceField"); + } + + @Test + public void mergeBeans() { + + var portableOptions = new TestPortableOptionsImpl(); + portableOptions.setName("John"); + portableOptions.setAge(30); + portableOptions.setNonInterfaceField("NonInterfaceField"); + + var specificOptions = new TestSpecificOptions(); + + specificOptions.setName("Mike"); + specificOptions.setAge(60); + specificOptions.setSpecificField("SpecificField"); + + TestSpecificOptions specificOptions2 = ModelOptionsUtils.mergeBeans(portableOptions, specificOptions, + TestPortableOptions.class, false); + + assertThat(specificOptions2.getAge()).isEqualTo(60); + assertThat(specificOptions2.getName()).isEqualTo("Mike"); + assertThat(specificOptions2.getSpecificField()).isEqualTo("SpecificField"); + + TestSpecificOptions specificOptionsWithOverride = ModelOptionsUtils.mergeBeans(portableOptions, specificOptions, + TestPortableOptions.class, true); + + assertThat(specificOptionsWithOverride.getAge()).isEqualTo(30); + assertThat(specificOptionsWithOverride.getName()).isEqualTo("John"); + assertThat(specificOptionsWithOverride.getSpecificField()).isEqualTo("SpecificField"); + } + + @Test + public void copyToTarget() { + var portableOptions = new TestPortableOptionsImpl(); + portableOptions.setName("John"); + portableOptions.setAge(30); + portableOptions.setNonInterfaceField("NonInterfaceField"); + + TestSpecificOptions target = ModelOptionsUtils.copyToTarget(portableOptions, TestPortableOptions.class, + TestSpecificOptions.class); + + assertThat(target.getAge()).isEqualTo(30); + assertThat(target.getName()).isEqualTo("John"); + assertThat(target.getSpecificField()).isNull(); + } + + @Test + public void getJsonPropertyValues() { + record TestRecord(@JsonProperty("field1") String fieldA, @JsonProperty("field2") String fieldB) { + } + assertThat(ModelOptionsUtils.getJsonPropertyValues(TestRecord.class)).hasSize(2); + assertThat(ModelOptionsUtils.getJsonPropertyValues(TestRecord.class)).containsExactly("field1", "field2"); + } + +} \ No newline at end of file diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/openai-chat.adoc index 3b2c4db85..73084e2e9 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/clients/openai-chat.adoc @@ -64,7 +64,7 @@ Flux response = chatClient.stream( The `OpenAiChatOptions` provides the configuration information for the chat requests. The `OpenAiChatOptions.Builder` is fluent options builder. -==== OpenAiChatOptions +==== ChatOptions and OpenAiChatOptions The https://github.com/spring-projects/spring-ai/blob/main/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java[OpenAiChatOptions.java] provides provides the configuration information for the chat requests, such as the model to use, the temperature, the frequency penalty, etc. @@ -87,6 +87,9 @@ ChatResponse response = chatClient.call( )); ---- +You can use as prompt options any instance that implements the portable `ChatOptions` interface. +For example you can use the `ChatOptionsBuilder` to create a portable prompt options. + === OpenAiChatClient Auto-configuration Spring AI provides Spring Boot auto-configuration for the OpenAI Chat Client.