diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java index 703134b509c..3dc6b23b020 100644 --- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java +++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java @@ -25,6 +25,8 @@ import java.util.Objects; import java.util.Set; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -292,7 +294,7 @@ public class OpenAiChatOptions implements ToolCallingChatOptions { * .build() * } */ - private @JsonProperty("extra_body") Map extraBody; + private Map extraBody = new HashMap<>(); // @formatter:on @@ -338,7 +340,7 @@ public static OpenAiChatOptions fromOptions(OpenAiChatOptions fromOptions) { .serviceTier(fromOptions.getServiceTier()) .promptCacheKey(fromOptions.getPromptCacheKey()) .safetyIdentifier(fromOptions.getSafetyIdentifier()) - .extraBody(fromOptions.getExtraBody()) + .extraBody(fromOptions.extraBody()) .build(); } @@ -535,12 +537,29 @@ public void setParallelToolCalls(Boolean parallelToolCalls) { this.parallelToolCalls = parallelToolCalls; } - public Map getExtraBody() { + /** + * Overrides the default accessor to add @JsonAnyGetter annotation. This causes + * Jackson to flatten the extraBody map contents to the top level of the JSON, + * matching the behavior expected by OpenAI-compatible servers like vLLM, Ollama, etc. + * @return The extraBody map, or null if not set. + */ + @JsonAnyGetter + public Map extraBody() { return this.extraBody; } - public void setExtraBody(Map extraBody) { - this.extraBody = extraBody; + /** + * Handles deserialization of unknown properties into the extraBody map. This enables + * JSON with extra fields to be deserialized into ChatCompletionRequest, which is + * useful for implementing OpenAI API proxy servers with @RestController. + * @param key The property name + * @param value The property value + */ + @JsonAnySetter + private void setExtraBodyProperty(String key, Object value) { + if (this.extraBody != null) { + this.extraBody.put(key, value); + } } @Override diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java b/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java index 079c8089ee5..dda307236ae 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java @@ -18,6 +18,7 @@ import java.beans.PropertyDescriptor; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; @@ -28,6 +29,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -83,7 +85,7 @@ public abstract class ModelOptionsUtils { private static final List BEAN_MERGE_FIELD_EXCISIONS = List.of("class"); - private static final ConcurrentHashMap, List> REQUEST_FIELD_NAMES_PER_CLASS = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap, JsonPropertyResult> REQUEST_FIELD_NAMES_PER_CLASS = new ConcurrentHashMap<>(); private static final AtomicReference SCHEMA_GENERATOR_CACHE = new AtomicReference<>(); @@ -182,10 +184,14 @@ public static T merge(Object source, Object target, Class clazz, List requestFieldNames = CollectionUtils.isEmpty(acceptedFieldNames) - ? REQUEST_FIELD_NAMES_PER_CLASS.computeIfAbsent(clazz, ModelOptionsUtils::getJsonPropertyValues) + ? REQUEST_FIELD_NAMES_PER_CLASS.computeIfAbsent(clazz, ModelOptionsUtils::getJsonPropertyResult) + .properties() : acceptedFieldNames; - if (CollectionUtils.isEmpty(requestFieldNames)) { + boolean acceptAllFields = REQUEST_FIELD_NAMES_PER_CLASS.containsKey(clazz) + && REQUEST_FIELD_NAMES_PER_CLASS.get(clazz).acceptAllFields; + + if (!acceptAllFields && CollectionUtils.isEmpty(requestFieldNames)) { throw new IllegalArgumentException("No @JsonProperty fields found in the " + clazz.getName()); } @@ -199,7 +205,7 @@ public static T merge(Object source, Object target, Class clazz, List requestFieldNames.contains(e.getKey())) + .filter(e -> acceptAllFields || requestFieldNames.contains(e.getKey())) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); return ModelOptionsUtils.mapToClass(targetMap, clazz); @@ -263,20 +269,34 @@ public static T mapToClass(Map source, Class clazz) { } /** - * Returns the list of name values of the {@link JsonProperty} annotations. + * Returns the result contains tag of accept all fields or list of json fields. * @param clazz the class that contains fields annotated with {@link JsonProperty}. - * @return the list of values of the {@link JsonProperty} annotations. + * @return the result contains tag of method has {@link JsonAnyGetter} annotation or + * list of values of the {@link JsonProperty} annotations. */ - public static List getJsonPropertyValues(Class clazz) { + public static JsonPropertyResult getJsonPropertyResult(Class clazz) { List values = new ArrayList<>(); Field[] fields = clazz.getDeclaredFields(); + Method[] methods = clazz.getDeclaredMethods(); + boolean hasAnyGetter = false; + + for (Method method : methods) { + // Iterate through the method to check JsonAnyGetter annotation to ensure that + // unknown parameters can be copied + JsonAnyGetter anyGetterAnnotation = method.getAnnotation(JsonAnyGetter.class); + if (anyGetterAnnotation != null) { + hasAnyGetter = true; + return new JsonPropertyResult(hasAnyGetter, values); + } + } + for (Field field : fields) { JsonProperty jsonPropertyAnnotation = field.getAnnotation(JsonProperty.class); if (jsonPropertyAnnotation != null) { values.add(jsonPropertyAnnotation.value()); } } - return values; + return new JsonPropertyResult(false, values); } /** @@ -452,4 +472,13 @@ public static T mergeOption(T runtimeValue, T defaultValue) { return ObjectUtils.isEmpty(runtimeValue) ? defaultValue : runtimeValue; } + /** + * Record the decision result of {@link #getJsonPropertyResult(Class)} + * + * @param acceptAllFields the current class allows tags for all properties + * @param properties list of properties allowed by the current class + */ + public record JsonPropertyResult(boolean acceptAllFields, List properties) { + } + } diff --git a/spring-ai-model/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java b/spring-ai-model/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java index 732a5cba1a4..91c7a047342 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/model/ModelOptionsUtilsTests.java @@ -16,8 +16,11 @@ package org.springframework.ai.model; +import java.util.HashMap; import java.util.Map; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -45,6 +48,9 @@ public void merge() { specificOptions.setName("Mike"); specificOptions.setSpecificField("SpecificField"); + TestSpecificOptions extraOptions = new TestSpecificOptions(); + extraOptions.setExtraBodyProperty("key", "value"); + assertThatThrownBy( () -> ModelOptionsUtils.merge(portableOptions, specificOptions, TestPortableOptionsImpl.class)) .isInstanceOf(IllegalArgumentException.class) @@ -56,6 +62,10 @@ public void merge() { assertThat(specificOptions2.getName()).isEqualTo("John"); // !!! Overridden by the // portableOptions assertThat(specificOptions2.getSpecificField()).isEqualTo("SpecificField"); + + var extraSpecOptions = ModelOptionsUtils.merge(extraOptions, specificOptions, TestSpecificOptions.class); + assertThat(extraSpecOptions.extraBody()).containsKey("key"); + assertThat(extraSpecOptions.extraBody().get("key")).isEqualTo("value"); } @Test @@ -171,12 +181,24 @@ public void pojo_emptyStringAsNullObject() throws Exception { } @Test - public void getJsonPropertyValues() { + public void getJsonPropertyResult() { 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"); + assertThat(ModelOptionsUtils.getJsonPropertyResult(TestRecord.class).properties()).hasSize(2); + assertThat(ModelOptionsUtils.getJsonPropertyResult(TestRecord.class).properties()).containsExactly("field1", + "field2"); + + record TestAnyGetterRecord(Map extraBody) { + + @JsonAnyGetter + public Map extraBody() { + return this.extraBody; + } + } + + assertThat(ModelOptionsUtils.getJsonPropertyResult(TestAnyGetterRecord.class).acceptAllFields()) + .isEqualTo(true); } @Test @@ -357,6 +379,8 @@ public static class TestSpecificOptions implements TestPortableOptions { @JsonProperty("age") private Integer age; + private Map extraBody = new HashMap<>(); + @Override public String getName() { return this.name; @@ -385,6 +409,32 @@ public void setSpecificField(String modelSpecificField) { this.specificField = modelSpecificField; } + /** + * Overrides the default accessor to add @JsonAnyGetter annotation. This causes + * Jackson to flatten the extraBody map contents to the top level of the JSON, + * matching the behavior expected by OpenAI-compatible servers like vLLM, Ollama, + * etc. + * @return The extraBody map, or null if not set. + */ + @JsonAnyGetter + public Map extraBody() { + return this.extraBody; + } + + /** + * Handles deserialization of unknown properties into the extraBody map. This + * enables JSON with extra fields to be deserialized into ChatCompletionRequest, + * which is useful for implementing OpenAI API proxy servers with @RestController. + * @param key The property name + * @param value The property value + */ + @JsonAnySetter + private void setExtraBodyProperty(String key, Object value) { + if (this.extraBody != null) { + this.extraBody.put(key, value); + } + } + @Override public String toString() { return "TestModelSpecificOptions{" + "specificField='" + this.specificField + '\'' + ", name='" + this.name