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:
@@ -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:"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user