Skip to content

Commit 4aaa0fd

Browse files
committed
fix(ai): sanitize providerConfig at construction time so all consumers receive clean JSON
1 parent b1dea4c commit 4aaa0fd

3 files changed

Lines changed: 59 additions & 23 deletions

File tree

dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ public AppConfig(final String host, final Map<String, Secret> secrets) {
6262
apiUrl = aiAppUtil.discoverEnvSecret(secrets, AppKeys.API_URL, AI_API_URL_KEY);
6363
apiImageUrl = aiAppUtil.discoverEnvSecret(secrets, AppKeys.API_IMAGE_URL, AI_IMAGE_API_URL_KEY);
6464
apiEmbeddingsUrl = aiAppUtil.discoverEnvSecret(secrets, AppKeys.API_EMBEDDINGS_URL, AI_EMBEDDINGS_API_URL_KEY);
65-
providerConfig = aiAppUtil.discoverSecret(secrets, AppKeys.PROVIDER_CONFIG);
65+
final String rawProviderConfig = aiAppUtil.discoverSecret(secrets, AppKeys.PROVIDER_CONFIG);
66+
providerConfig = rawProviderConfig != null ? rawProviderConfig.replaceAll("[\\r\\n\\t]", "") : null;
6667

6768
if (StringUtils.isNotBlank(providerConfig)) {
6869
providerConfigHash = sha256Hex(providerConfig);
@@ -349,7 +350,7 @@ public boolean isEnabled() {
349350
@com.google.common.annotations.VisibleForTesting
350351
static JsonNode parseProviderConfig(final String json) {
351352
try {
352-
return MAPPER.readTree(json.replaceAll("[\\r\\n\\t]", ""));
353+
return MAPPER.readTree(json);
353354
} catch (final Exception e) {
354355
Logger.warn(AppConfig.class, "Failed to parse providerConfig JSON"
355356
+ " (" + e.getClass().getSimpleName() + "): " + e.getMessage(), e);

dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import com.dotcms.ai.domain.AIProvider;
1010
import com.dotcms.ai.exception.DotAIAppConfigDisabledException;
1111
import com.dotcms.ai.exception.DotAIClientConnectException;
12-
import com.dotcms.rest.api.v1.DotObjectMapperProvider;
1312
import com.dotmarketing.util.Logger;
1413
import com.dotmarketing.util.json.JSONArray;
1514
import com.dotmarketing.util.json.JSONObject;
@@ -66,7 +65,7 @@
6665
public class LangChain4jAIClient implements AIClient {
6766

6867
private static final Lazy<LangChain4jAIClient> INSTANCE = Lazy.of(LangChain4jAIClient::new);
69-
private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper();
68+
private static final ObjectMapper MAPPER = new ObjectMapper();
7069
private static final long MODEL_CACHE_TTL_HOURS = 1;
7170

7271
private final Cache<String, ChatModel> chatModelCache = CacheBuilder.newBuilder()

dotCMS/src/test/java/com/dotcms/ai/app/AppConfigTest.java

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,39 +68,75 @@ public class AppConfigTest {
6868
"}";
6969

7070
// -------------------------------------------------------------------------
71-
// parseProviderConfig — unit tests on the static method directly
71+
// Real-world JSON format from the dotAI Apps config (formatted, 3 sections)
72+
// API key matches the actual structure: sk-proj-<164 chars>
7273
// -------------------------------------------------------------------------
7374

75+
private static final String REAL_API_KEY =
76+
"sk-proj-EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE" +
77+
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF";
78+
79+
/** Exact format the user pastes into the Apps UI textarea (pretty-printed, 3 sections). */
80+
private static final String REAL_WORLD_FORMATTED_CONFIG =
81+
"{\n" +
82+
" \"chat\":{\n" +
83+
" \"provider\":\"openai\",\n" +
84+
" \"apiKey\":\"" + REAL_API_KEY + "\",\n" +
85+
" \"model\":\"o4-mini\",\n" +
86+
" \"maxCompletionTokens\":16384,\n" +
87+
" \"temperature\":1.0,\n" +
88+
" \"maxRetries\":3\n" +
89+
" },\n" +
90+
" \"embeddings\":{\n" +
91+
" \"provider\":\"openai\",\n" +
92+
" \"apiKey\":\"" + REAL_API_KEY + "\",\n" +
93+
" \"model\":\"text-embedding-3-small\"\n" +
94+
" },\n" +
95+
" \"image\":{\n" +
96+
" \"provider\":\"openai\",\n" +
97+
" \"apiKey\":\"" + REAL_API_KEY + "\",\n" +
98+
" \"model\":\"gpt-image-1\",\n" +
99+
" \"size\":\"1024x1024\"\n" +
100+
" }\n" +
101+
"}";
102+
74103
@Test
75-
public void test_parseProviderConfig_cleanJson_parsesModelNames() {
76-
final JsonNode root = AppConfig.parseProviderConfig(CLEAN_PROVIDER_CONFIG);
104+
public void test_parseProviderConfig_realWorldFormattedJson_parsesAllSections() {
105+
final JsonNode root = AppConfig.parseProviderConfig(REAL_WORLD_FORMATTED_CONFIG);
77106

78-
assertFalse("Parse should not return empty node for valid JSON", root.isEmpty());
79-
assertEquals("gpt-4o-mini", root.path("chat").path("model").asText());
80-
assertEquals("text-embedding-ada-002", root.path("embeddings").path("model").asText());
107+
assertFalse("Formatted JSON should parse successfully", root.isEmpty());
108+
assertEquals("o4-mini", root.path("chat").path("model").asText());
109+
assertEquals("text-embedding-3-small", root.path("embeddings").path("model").asText());
110+
assertEquals("gpt-image-1", root.path("image").path("model").asText());
81111
}
82112

83113
@Test
84-
public void test_parseProviderConfig_embeddedNewlines_parsesModelNames() {
85-
// This is the real-world failure scenario: apiKey contains a literal \n
86-
final JsonNode root = AppConfig.parseProviderConfig(WRAPPED_PROVIDER_CONFIG);
114+
public void test_appConfig_realWorldFormattedJson_isEnabled_allModelsSet() {
115+
final AppConfig config = buildAppConfig(REAL_WORLD_FORMATTED_CONFIG);
87116

88-
assertFalse("Parse should succeed even with embedded newlines in string values", root.isEmpty());
89-
assertEquals("gpt-4o-mini", root.path("chat").path("model").asText());
90-
assertEquals("text-embedding-ada-002", root.path("embeddings").path("model").asText());
117+
assertTrue("AppConfig should be enabled with real-world formatted providerConfig", config.isEnabled());
118+
assertEquals("o4-mini", config.getModel().getCurrentModel());
119+
assertEquals("text-embedding-3-small", config.getEmbeddingsModel().getCurrentModel());
120+
assertEquals("gpt-image-1", config.getImageModel().getCurrentModel());
91121
}
92122

93-
@Test
94-
public void test_parseProviderConfig_windowsLineEndings_parsesModelNames() {
95-
// Windows-style \r\n in the apiKey values
96-
final String crlfJson = WRAPPED_PROVIDER_CONFIG.replace("\n", "\r\n");
123+
// -------------------------------------------------------------------------
124+
// parseProviderConfig — unit tests on the static method directly
125+
// -------------------------------------------------------------------------
97126

98-
final JsonNode root = AppConfig.parseProviderConfig(crlfJson);
127+
@Test
128+
public void test_parseProviderConfig_cleanJson_parsesModelNames() {
129+
final JsonNode root = AppConfig.parseProviderConfig(CLEAN_PROVIDER_CONFIG);
99130

100-
assertFalse(root.isEmpty());
101-
assertEquals("gpt-4o-mini", root.path("chat").path("model").asText());
131+
assertFalse("Parse should not return empty node for valid JSON", root.isEmpty());
132+
assertEquals("gpt-4o-mini", root.path("chat").path("model").asText());
133+
assertEquals("text-embedding-ada-002", root.path("embeddings").path("model").asText());
102134
}
103135

136+
// Note: parseProviderConfig receives already-sanitized JSON (sanitization happens in the
137+
// constructor before calling this method). Embedded-newline scenarios are covered by
138+
// test_appConfig_withWrappedProviderConfig_isEnabled below.
139+
104140
@Test
105141
public void test_parseProviderConfig_null_returnsEmptyObjectNode() {
106142
final JsonNode root = AppConfig.parseProviderConfig(null);

0 commit comments

Comments
 (0)