Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -292,7 +294,7 @@ public class OpenAiChatOptions implements ToolCallingChatOptions {
* .build()
* }</pre>
*/
private @JsonProperty("extra_body") Map<String, Object> extraBody;
private Map<String, Object> extraBody = new HashMap<>();

// @formatter:on

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -535,12 +537,29 @@ public void setParallelToolCalls(Boolean parallelToolCalls) {
this.parallelToolCalls = parallelToolCalls;
}

public Map<String, Object> 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<String, Object> extraBody() {
return this.extraBody;
}

public void setExtraBody(Map<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -83,7 +85,7 @@ public abstract class ModelOptionsUtils {

private static final List<String> BEAN_MERGE_FIELD_EXCISIONS = List.of("class");

private static final ConcurrentHashMap<Class<?>, List<String>> REQUEST_FIELD_NAMES_PER_CLASS = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<Class<?>, JsonPropertyResult> REQUEST_FIELD_NAMES_PER_CLASS = new ConcurrentHashMap<>();

private static final AtomicReference<SchemaGenerator> SCHEMA_GENERATOR_CACHE = new AtomicReference<>();

Expand Down Expand Up @@ -182,10 +184,14 @@ public static <T> T merge(Object source, Object target, Class<T> clazz, List<Str
}

List<String> 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());
}

Expand All @@ -199,7 +205,7 @@ public static <T> T merge(Object source, Object target, Class<T> clazz, List<Str

targetMap = targetMap.entrySet()
.stream()
.filter(e -> 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);
Expand Down Expand Up @@ -263,20 +269,34 @@ public static <T> T mapToClass(Map<String, Object> source, Class<T> 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<String> getJsonPropertyValues(Class<?> clazz) {
public static JsonPropertyResult getJsonPropertyResult(Class<?> clazz) {
List<String> 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);
}

/**
Expand Down Expand Up @@ -452,4 +472,13 @@ public static <T> 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<String> properties) {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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<String, Object> extraBody) {

@JsonAnyGetter
public Map<String, Object> extraBody() {
return this.extraBody;
}
}

assertThat(ModelOptionsUtils.getJsonPropertyResult(TestAnyGetterRecord.class).acceptAllFields())
.isEqualTo(true);
}

@Test
Expand Down Expand Up @@ -357,6 +379,8 @@ public static class TestSpecificOptions implements TestPortableOptions {
@JsonProperty("age")
private Integer age;

private Map<String, Object> extraBody = new HashMap<>();

@Override
public String getName() {
return this.name;
Expand Down Expand Up @@ -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<String, Object> 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
Expand Down