Fix handling for openai chat portable options

- Now you can use an arbitrary ChatOption instance as Prompt options for the OpenAIChatClinet.
 - Add Unit tests for OpenAiApi and ModelOptionsUtils.
 - Document portable options support.
This commit is contained in:
Christian Tzolov
2024-02-02 15:28:40 +01:00
parent d71f90fd56
commit 60a60b4610
5 changed files with 359 additions and 15 deletions

View File

@@ -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:"

View File

@@ -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);

View File

@@ -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<String> BEAN_MERGE_FIELD_EXCISIONS = List.of("class");
private static ConcurrentHashMap<Class<?>, List<String>> REQUEST_FIELD_NAMES_PER_CLASS = new ConcurrentHashMap<Class<?>, List<String>>();
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 <T> 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<String, Object> sourceMap = objectToMap(source);
Map<String, Object> targetMap = objectToMap(target);
if (CollectionUtils.isEmpty(requestFieldNames)) {
throw new IllegalArgumentException("No @JsonProperty fields found in the " + clazz.getName());
}
Map<String, Object> sourceMap = ModelOptionsUtils.objectToMap(source);
Map<String, Object> 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<Class<?>, List<String>> REQUEST_FIELD_NAMES_PER_CLASS = new ConcurrentHashMap<Class<?>, List<String>>();
/**
* 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 <T> 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> T merge(Object source, Object target, Class<T> 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 <T> the target class type.
* @return a new instance of the targetBeanClazz with the values from the sourceBean
* instance.
*/
public static <I, S extends I, T extends S> T copyToTarget(S sourceBean, Class<I> sourceInterfaceClazz,
Class<T> 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 <T> 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 <I, S extends I, T extends S> T mergeBeans(S source, T target, Class<I> 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<String> 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);
}
}

View File

@@ -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<String, Object> 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");
}
}

View File

@@ -64,7 +64,7 @@ Flux<ChatResponse> 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.