Skip to content
Draft
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
72 changes: 72 additions & 0 deletions auto-configurations/common/spring-ai-autoconfigure-jackson/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-parent</artifactId>
<version>2.0.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>spring-ai-autoconfigure-jackson</artifactId>
<packaging>jar</packaging>
<name>Spring AI Model Auto Configuration</name>
<description>Spring AI Model Auto Configuration</description>
<url>https://github.com/spring-projects/spring-ai</url>

<scm>
<url>https://github.com/spring-projects/spring-ai</url>
<connection>git://github.com/spring-projects/spring-ai.git</connection>
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
</scm>


<dependencies>

<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
<version>${project.parent.version}</version>
</dependency>

<!-- Boot dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure-processor</artifactId>
<optional>true</optional>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-test</artifactId>
<version>${project.parent.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright 2023-2025 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.autoconfigure;

import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.cfg.CoercionAction;
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
import com.fasterxml.jackson.databind.json.JsonMapper;

import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.util.JacksonUtils;
import org.springframework.ai.util.json.JsonParser;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;

/**
* {@link AutoConfiguration Auto-configuration} for Spring AI ObjectMapper beans. Provides
* customizable ObjectMapper beans for JSON parsing and model options handling. Users can
* override these beans to customize JSON processing behavior.
*
* @author Daniel Albuquerque
* @since 2.0.0
*/
@AutoConfiguration
@ConditionalOnClass({ JsonParser.class, ObjectMapper.class })
public class ObjectMapperAutoConfiguration {

/**
* Creates an ObjectMapper bean configured for lenient JSON parsing of LLM responses.
* This ObjectMapper is used by {@link JsonParser} for tool calling and structured
* output.
*
* <p>
* Default configuration:
* <ul>
* <li>Allows unescaped control characters (common in LLM responses)</li>
* <li>Ignores unknown properties</li>
* <li>Serializes dates as ISO strings instead of timestamps</li>
* <li>Allows empty beans to be serialized</li>
* </ul>
*
* <p>
* Users can override this bean by defining their own bean with the name
* "jsonParserObjectMapper".
* @return configured ObjectMapper for JSON parsing
*/
@Bean(name = "jsonParserObjectMapper", defaultCandidate = false)
@ConditionalOnMissingBean(name = "jsonParserObjectMapper")
public ObjectMapper jsonParserObjectMapper() {
ObjectMapper mapper = JsonMapper.builder()
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.addModules(JacksonUtils.instantiateAvailableModules())
.build();

// Set this ObjectMapper to be used by JsonParser
JsonParser.setObjectMapper(mapper);

return mapper;
}

/**
* Creates an ObjectMapper bean configured for model options
* serialization/deserialization. This ObjectMapper is used by
* {@link ModelOptionsUtils} for converting model options.
*
* <p>
* Default configuration:
* <ul>
* <li>Ignores unknown properties</li>
* <li>Allows empty beans to be serialized</li>
* <li>Accepts empty strings as null objects</li>
* <li>Coerces empty strings to null for Enum types</li>
* </ul>
*
* <p>
* Users can override this bean by defining their own bean with the name
* "modelOptionsObjectMapper".
* @return configured ObjectMapper for model options
*/
@Bean(name = "modelOptionsObjectMapper", defaultCandidate = false)
@ConditionalOnMissingBean(name = "modelOptionsObjectMapper")
public ObjectMapper modelOptionsObjectMapper() {
ObjectMapper mapper = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.addModules(JacksonUtils.instantiateAvailableModules())
.build()
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);

// Configure coercion for empty strings to null for Enum types
// This fixes the issue where empty string finish_reason values cause
// deserialization failures
mapper.coercionConfigFor(Enum.class).setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);

// Set this ObjectMapper to be used by ModelOptionsUtils
ModelOptionsUtils.setObjectMapper(mapper);

return mapper;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.springframework.ai.model.autoconfigure.ObjectMapperAutoConfiguration
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@


<module>auto-configurations/common/spring-ai-autoconfigure-retry</module>
<module>auto-configurations/common/spring-ai-autoconfigure-jackson</module>

<module>auto-configurations/models/tool/spring-ai-autoconfigure-model-tool</module>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,18 +67,42 @@
*/
public abstract class ModelOptionsUtils {

public static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.addModules(JacksonUtils.instantiateAvailableModules())
.build()
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
private static final ObjectMapper DEFAULT_OBJECT_MAPPER;

static {
DEFAULT_OBJECT_MAPPER = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.addModules(JacksonUtils.instantiateAvailableModules())
.build()
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);

// Configure coercion for empty strings to null for Enum types
// This fixes the issue where empty string finish_reason values cause
// deserialization failures
OBJECT_MAPPER.coercionConfigFor(Enum.class).setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
DEFAULT_OBJECT_MAPPER.coercionConfigFor(Enum.class)
.setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
}

private static final AtomicReference<ObjectMapper> OBJECT_MAPPER = new AtomicReference<>(DEFAULT_OBJECT_MAPPER);

/**
* Sets a custom {@link ObjectMapper} instance to use for model options operations.
* This is typically called by Spring's auto-configuration to inject a configured
* ObjectMapper bean.
* @param objectMapper the ObjectMapper to use
*/
public static void setObjectMapper(ObjectMapper objectMapper) {
org.springframework.util.Assert.notNull(objectMapper, "objectMapper cannot be null");
OBJECT_MAPPER.set(objectMapper);
}

/**
* Returns the {@link ObjectMapper} instance used for model options operations.
* @return the ObjectMapper instance
*/
public static ObjectMapper getObjectMapper() {
return OBJECT_MAPPER.get();
}

private static final List<String> BEAN_MERGE_FIELD_EXCISIONS = List.of("class");
Expand All @@ -98,7 +122,7 @@ public abstract class ModelOptionsUtils {
* @return the converted Map.
*/
public static Map<String, Object> jsonToMap(String json) {
return jsonToMap(json, OBJECT_MAPPER);
return jsonToMap(json, OBJECT_MAPPER.get());
}

/**
Expand Down Expand Up @@ -126,7 +150,7 @@ public static Map<String, Object> jsonToMap(String json, ObjectMapper objectMapp
*/
public static <T> T jsonToObject(String json, Class<T> type) {
try {
return OBJECT_MAPPER.readValue(json, type);
return OBJECT_MAPPER.get().readValue(json, type);
}
catch (Exception e) {
throw new RuntimeException("Failed to json: " + json, e);
Expand All @@ -140,7 +164,7 @@ public static <T> T jsonToObject(String json, Class<T> type) {
*/
public static String toJsonString(Object object) {
try {
return OBJECT_MAPPER.writeValueAsString(object);
return OBJECT_MAPPER.get().writeValueAsString(object);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
Expand All @@ -154,7 +178,7 @@ public static String toJsonString(Object object) {
*/
public static String toJsonStringPrettyPrinter(Object object) {
try {
return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(object);
return OBJECT_MAPPER.get().writerWithDefaultPrettyPrinter().writeValueAsString(object);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
Expand Down Expand Up @@ -231,8 +255,9 @@ public static Map<String, Object> objectToMap(Object source) {
return new HashMap<>();
}
try {
String json = OBJECT_MAPPER.writeValueAsString(source);
return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {
ObjectMapper mapper = OBJECT_MAPPER.get();
String json = mapper.writeValueAsString(source);
return mapper.readValue(json, new TypeReference<Map<String, Object>>() {

})
.entrySet()
Expand All @@ -254,8 +279,9 @@ public static Map<String, Object> objectToMap(Object source) {
*/
public static <T> T mapToClass(Map<String, Object> source, Class<T> clazz) {
try {
String json = OBJECT_MAPPER.writeValueAsString(source);
return OBJECT_MAPPER.readValue(json, clazz);
ObjectMapper mapper = OBJECT_MAPPER.get();
String json = mapper.writeValueAsString(source);
return mapper.readValue(json, clazz);
}
catch (JsonProcessingException e) {
throw new RuntimeException(e);
Expand Down
Loading