Skip to content

Commit 4ffc7ac

Browse files
committed
Allow using spring's object mapper
Signed-off-by: Daniel Albuquerque <daniel.albuquerque@teya.com>
1 parent 920e6f4 commit 4ffc7ac

File tree

7 files changed

+274
-36
lines changed

7 files changed

+274
-36
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xmlns="http://maven.apache.org/POM/4.0.0"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
<parent>
7+
<groupId>org.springframework.ai</groupId>
8+
<artifactId>spring-ai-parent</artifactId>
9+
<version>2.0.0-SNAPSHOT</version>
10+
<relativePath>../../../pom.xml</relativePath>
11+
</parent>
12+
<artifactId>spring-ai-autoconfigure-jackson</artifactId>
13+
<packaging>jar</packaging>
14+
<name>Spring AI Model Auto Configuration</name>
15+
<description>Spring AI Model Auto Configuration</description>
16+
<url>https://github.com/spring-projects/spring-ai</url>
17+
18+
<scm>
19+
<url>https://github.com/spring-projects/spring-ai</url>
20+
<connection>git://github.com/spring-projects/spring-ai.git</connection>
21+
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
22+
</scm>
23+
24+
25+
<dependencies>
26+
27+
<dependency>
28+
<groupId>org.springframework.ai</groupId>
29+
<artifactId>spring-ai-model</artifactId>
30+
<version>${project.parent.version}</version>
31+
</dependency>
32+
33+
<!-- Boot dependencies -->
34+
<dependency>
35+
<groupId>org.springframework.boot</groupId>
36+
<artifactId>spring-boot-starter</artifactId>
37+
</dependency>
38+
39+
<dependency>
40+
<groupId>org.springframework.boot</groupId>
41+
<artifactId>spring-boot-configuration-processor</artifactId>
42+
<optional>true</optional>
43+
</dependency>
44+
45+
<dependency>
46+
<groupId>org.springframework.boot</groupId>
47+
<artifactId>spring-boot-autoconfigure-processor</artifactId>
48+
<optional>true</optional>
49+
</dependency>
50+
51+
<!-- Test dependencies -->
52+
<dependency>
53+
<groupId>org.springframework.ai</groupId>
54+
<artifactId>spring-ai-test</artifactId>
55+
<version>${project.parent.version}</version>
56+
<scope>test</scope>
57+
</dependency>
58+
59+
<dependency>
60+
<groupId>org.springframework.boot</groupId>
61+
<artifactId>spring-boot-starter-test</artifactId>
62+
<scope>test</scope>
63+
</dependency>
64+
65+
<dependency>
66+
<groupId>org.mockito</groupId>
67+
<artifactId>mockito-core</artifactId>
68+
<scope>test</scope>
69+
</dependency>
70+
</dependencies>
71+
72+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.model.autoconfigure;
18+
19+
import com.fasterxml.jackson.core.json.JsonReadFeature;
20+
import com.fasterxml.jackson.databind.DeserializationFeature;
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.fasterxml.jackson.databind.SerializationFeature;
23+
import com.fasterxml.jackson.databind.cfg.CoercionAction;
24+
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
25+
import com.fasterxml.jackson.databind.json.JsonMapper;
26+
27+
import org.springframework.ai.model.ModelOptionsUtils;
28+
import org.springframework.ai.util.JacksonUtils;
29+
import org.springframework.ai.util.json.JsonParser;
30+
import org.springframework.boot.autoconfigure.AutoConfiguration;
31+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
32+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
33+
import org.springframework.context.annotation.Bean;
34+
35+
/**
36+
* {@link AutoConfiguration Auto-configuration} for Spring AI ObjectMapper beans. Provides
37+
* customizable ObjectMapper beans for JSON parsing and model options handling. Users can
38+
* override these beans to customize JSON processing behavior.
39+
*
40+
* @author Daniel Albuquerque
41+
* @since 2.0.0
42+
*/
43+
@AutoConfiguration
44+
@ConditionalOnClass({ JsonParser.class, ObjectMapper.class })
45+
public class ObjectMapperAutoConfiguration {
46+
47+
/**
48+
* Creates an ObjectMapper bean configured for lenient JSON parsing of LLM responses.
49+
* This ObjectMapper is used by {@link JsonParser} for tool calling and structured
50+
* output.
51+
*
52+
* <p>
53+
* Default configuration:
54+
* <ul>
55+
* <li>Allows unescaped control characters (common in LLM responses)</li>
56+
* <li>Ignores unknown properties</li>
57+
* <li>Serializes dates as ISO strings instead of timestamps</li>
58+
* <li>Allows empty beans to be serialized</li>
59+
* </ul>
60+
*
61+
* <p>
62+
* Users can override this bean by defining their own bean with the name
63+
* "jsonParserObjectMapper".
64+
* @return configured ObjectMapper for JSON parsing
65+
*/
66+
@Bean(name = "jsonParserObjectMapper", defaultCandidate = false)
67+
@ConditionalOnMissingBean(name = "jsonParserObjectMapper")
68+
public ObjectMapper jsonParserObjectMapper() {
69+
ObjectMapper mapper = JsonMapper.builder()
70+
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS)
71+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
72+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
73+
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
74+
.addModules(JacksonUtils.instantiateAvailableModules())
75+
.build();
76+
77+
// Set this ObjectMapper to be used by JsonParser
78+
JsonParser.setObjectMapper(mapper);
79+
80+
return mapper;
81+
}
82+
83+
/**
84+
* Creates an ObjectMapper bean configured for model options
85+
* serialization/deserialization. This ObjectMapper is used by
86+
* {@link ModelOptionsUtils} for converting model options.
87+
*
88+
* <p>
89+
* Default configuration:
90+
* <ul>
91+
* <li>Ignores unknown properties</li>
92+
* <li>Allows empty beans to be serialized</li>
93+
* <li>Accepts empty strings as null objects</li>
94+
* <li>Coerces empty strings to null for Enum types</li>
95+
* </ul>
96+
*
97+
* <p>
98+
* Users can override this bean by defining their own bean with the name
99+
* "modelOptionsObjectMapper".
100+
* @return configured ObjectMapper for model options
101+
*/
102+
@Bean(name = "modelOptionsObjectMapper", defaultCandidate = false)
103+
@ConditionalOnMissingBean(name = "modelOptionsObjectMapper")
104+
public ObjectMapper modelOptionsObjectMapper() {
105+
ObjectMapper mapper = JsonMapper.builder()
106+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
107+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
108+
.addModules(JacksonUtils.instantiateAvailableModules())
109+
.build()
110+
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
111+
112+
// Configure coercion for empty strings to null for Enum types
113+
// This fixes the issue where empty string finish_reason values cause
114+
// deserialization failures
115+
mapper.coercionConfigFor(Enum.class).setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
116+
117+
// Set this ObjectMapper to be used by ModelOptionsUtils
118+
ModelOptionsUtils.setObjectMapper(mapper);
119+
120+
return mapper;
121+
}
122+
123+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
org.springframework.ai.model.autoconfigure.ObjectMapperAutoConfiguration

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383

8484

8585
<module>auto-configurations/common/spring-ai-autoconfigure-retry</module>
86+
<module>auto-configurations/common/spring-ai-autoconfigure-model</module>
8687

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

spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,42 @@
6767
*/
6868
public abstract class ModelOptionsUtils {
6969

70-
public static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
71-
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
72-
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
73-
.addModules(JacksonUtils.instantiateAvailableModules())
74-
.build()
75-
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
70+
private static final ObjectMapper DEFAULT_OBJECT_MAPPER;
7671

7772
static {
73+
DEFAULT_OBJECT_MAPPER = JsonMapper.builder()
74+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
75+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
76+
.addModules(JacksonUtils.instantiateAvailableModules())
77+
.build()
78+
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
79+
7880
// Configure coercion for empty strings to null for Enum types
7981
// This fixes the issue where empty string finish_reason values cause
8082
// deserialization failures
81-
OBJECT_MAPPER.coercionConfigFor(Enum.class).setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
83+
DEFAULT_OBJECT_MAPPER.coercionConfigFor(Enum.class)
84+
.setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
85+
}
86+
87+
private static final AtomicReference<ObjectMapper> OBJECT_MAPPER = new AtomicReference<>(DEFAULT_OBJECT_MAPPER);
88+
89+
/**
90+
* Sets a custom {@link ObjectMapper} instance to use for model options operations.
91+
* This is typically called by Spring's auto-configuration to inject a configured
92+
* ObjectMapper bean.
93+
* @param objectMapper the ObjectMapper to use
94+
*/
95+
public static void setObjectMapper(ObjectMapper objectMapper) {
96+
org.springframework.util.Assert.notNull(objectMapper, "objectMapper cannot be null");
97+
OBJECT_MAPPER.set(objectMapper);
98+
}
99+
100+
/**
101+
* Returns the {@link ObjectMapper} instance used for model options operations.
102+
* @return the ObjectMapper instance
103+
*/
104+
public static ObjectMapper getObjectMapper() {
105+
return OBJECT_MAPPER.get();
82106
}
83107

84108
private static final List<String> BEAN_MERGE_FIELD_EXCISIONS = List.of("class");
@@ -98,7 +122,7 @@ public abstract class ModelOptionsUtils {
98122
* @return the converted Map.
99123
*/
100124
public static Map<String, Object> jsonToMap(String json) {
101-
return jsonToMap(json, OBJECT_MAPPER);
125+
return jsonToMap(json, OBJECT_MAPPER.get());
102126
}
103127

104128
/**
@@ -126,7 +150,7 @@ public static Map<String, Object> jsonToMap(String json, ObjectMapper objectMapp
126150
*/
127151
public static <T> T jsonToObject(String json, Class<T> type) {
128152
try {
129-
return OBJECT_MAPPER.readValue(json, type);
153+
return OBJECT_MAPPER.get().readValue(json, type);
130154
}
131155
catch (Exception e) {
132156
throw new RuntimeException("Failed to json: " + json, e);
@@ -140,7 +164,7 @@ public static <T> T jsonToObject(String json, Class<T> type) {
140164
*/
141165
public static String toJsonString(Object object) {
142166
try {
143-
return OBJECT_MAPPER.writeValueAsString(object);
167+
return OBJECT_MAPPER.get().writeValueAsString(object);
144168
}
145169
catch (JsonProcessingException e) {
146170
throw new RuntimeException(e);
@@ -154,7 +178,7 @@ public static String toJsonString(Object object) {
154178
*/
155179
public static String toJsonStringPrettyPrinter(Object object) {
156180
try {
157-
return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(object);
181+
return OBJECT_MAPPER.get().writerWithDefaultPrettyPrinter().writeValueAsString(object);
158182
}
159183
catch (JsonProcessingException e) {
160184
throw new RuntimeException(e);
@@ -231,8 +255,9 @@ public static Map<String, Object> objectToMap(Object source) {
231255
return new HashMap<>();
232256
}
233257
try {
234-
String json = OBJECT_MAPPER.writeValueAsString(source);
235-
return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {
258+
ObjectMapper mapper = OBJECT_MAPPER.get();
259+
String json = mapper.writeValueAsString(source);
260+
return mapper.readValue(json, new TypeReference<Map<String, Object>>() {
236261

237262
})
238263
.entrySet()
@@ -254,8 +279,9 @@ public static Map<String, Object> objectToMap(Object source) {
254279
*/
255280
public static <T> T mapToClass(Map<String, Object> source, Class<T> clazz) {
256281
try {
257-
String json = OBJECT_MAPPER.writeValueAsString(source);
258-
return OBJECT_MAPPER.readValue(json, clazz);
282+
ObjectMapper mapper = OBJECT_MAPPER.get();
283+
String json = mapper.writeValueAsString(source);
284+
return mapper.readValue(json, clazz);
259285
}
260286
catch (JsonProcessingException e) {
261287
throw new RuntimeException(e);

0 commit comments

Comments
 (0)