Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6b94e4f
feat(dotAI): consolidate config into single providerConfig JSON with …
ihoffmann-dot Apr 21, 2026
99944b6
fix(dotAI): restore original YAML description, remove redundant field…
ihoffmann-dot Apr 21, 2026
12dbb1f
fix(dotAI): set providerConfig as visible field (hidden: false)
ihoffmann-dot Apr 21, 2026
1302acd
fix(dotAI): disable allowExtraParameters to remove Custom Properties …
ihoffmann-dot Apr 21, 2026
5805599
temp: revert allowExtraParameters to true for cleanup
ihoffmann-dot Apr 21, 2026
1f1f494
feat(ai): multi-model fallback via comma-separated model field
ihoffmann-dot Apr 21, 2026
5cb58a2
refactor(ai): remove PUT config endpoint and ProviderConfigMerger
ihoffmann-dot Apr 22, 2026
d010f7f
refactor(ai): extract executeWithFallback helper in LangChain4jAIClient
ihoffmann-dot Apr 22, 2026
5c73e87
fix(postman): update AI collection for providerConfig consolidation
ihoffmann-dot Apr 22, 2026
b6321ca
fix(ai): move listenerIndexer into providerConfig in AiTest setup
ihoffmann-dot Apr 23, 2026
9985b5b
Merge branch 'main' into dot-ai-langchain-fixes
ihoffmann-dot Apr 23, 2026
7dcb94f
feat(ai): add separate apiKey SECRET field to hide credentials in App…
ihoffmann-dot Apr 23, 2026
533e62b
fix(ai): correct apiKey field type to STRING with hidden:true in dotA…
ihoffmann-dot Apr 23, 2026
6ed266f
refactor(ai): address PR review comments on LangChain4jAIClient and P…
ihoffmann-dot Apr 23, 2026
42ee39e
feat(ai): auto-route maxTokens to max_completion_tokens for OpenAI re…
ihoffmann-dot Apr 23, 2026
cb95a36
docs(ai): update dotAI.yml description to reference OpenAI instead of…
ihoffmann-dot Apr 23, 2026
b1c3404
revert(ai): keep model() as @Nullable String in ProviderConfig
ihoffmann-dot Apr 23, 2026
615a4f8
fix(ai): flush SSE chunks, cancelled flag on IOException, maxRetries …
ihoffmann-dot Apr 23, 2026
ac9244d
fix(ai): null check in parseSection, deepCopy in injectApiKeyIntoSect…
ihoffmann-dot Apr 23, 2026
24baed2
Merge branch 'main' into dot-ai-langchain-fixes
ihoffmann-dot Apr 23, 2026
de7a43a
fix(ai): immutable allModels(), fallback tests, self-import, javadoc,…
ihoffmann-dot Apr 24, 2026
eb277be
fix(ai): correct dotAI.yml hint — apiKey must not be in providerConfig
ihoffmann-dot Apr 24, 2026
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
76 changes: 68 additions & 8 deletions dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.dotmarketing.util.UtilMethods;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.liferay.util.StringPool;
import io.vavr.Tuple;
import io.vavr.Tuple2;
Expand Down Expand Up @@ -51,7 +52,9 @@ public class AppConfig implements Serializable {
private final String imageSize;
private final String listenerIndexer;
private final String providerConfig;
private final String resolvedProviderConfig;
private final String providerConfigHash;
private final transient JsonNode providerConfigRoot;
private final Map<String, Secret> configValues;

public AppConfig(final String host, final Map<String, Secret> secrets) {
Expand All @@ -66,23 +69,28 @@ public AppConfig(final String host, final Map<String, Secret> secrets) {
providerConfig = rawProviderConfig != null ? rawProviderConfig.replaceAll("[\\r\\n\\t]", "") : null;

if (StringUtils.isNotBlank(providerConfig)) {
providerConfigHash = DigestUtils.sha256Hex(providerConfig);
final JsonNode providerConfigRoot = parseProviderConfig(providerConfig);
providerConfigRoot = parseProviderConfig(providerConfig);
resolvedProviderConfig = StringUtils.isNotBlank(apiKey)
? injectApiKeyIntoSections(providerConfigRoot, apiKey)
: providerConfig;
providerConfigHash = DigestUtils.sha256Hex(resolvedProviderConfig);
model = buildModelFromProviderConfigNode(providerConfigRoot, "chat", AIModelType.TEXT);
imageModel = buildModelFromProviderConfigNode(providerConfigRoot, "image", AIModelType.IMAGE);
embeddingsModel = buildModelFromProviderConfigNode(providerConfigRoot, "embeddings", AIModelType.EMBEDDINGS);
} else {
providerConfigHash = "no-config";
providerConfigRoot = MAPPER.createObjectNode();
resolvedProviderConfig = null;
model = AIModel.NOOP_MODEL;
imageModel = AIModel.NOOP_MODEL;
embeddingsModel = AIModel.NOOP_MODEL;
}

rolePrompt = aiAppUtil.discoverSecret(secrets, AppKeys.ROLE_PROMPT);
textPrompt = aiAppUtil.discoverSecret(secrets, AppKeys.TEXT_PROMPT);
imagePrompt = aiAppUtil.discoverSecret(secrets, AppKeys.IMAGE_PROMPT);
imageSize = aiAppUtil.discoverSecret(secrets, AppKeys.IMAGE_SIZE);
listenerIndexer = aiAppUtil.discoverSecret(secrets, AppKeys.LISTENER_INDEXER);
rolePrompt = getFromSection(providerConfigRoot, "chat", "rolePrompt", AppKeys.ROLE_PROMPT.defaultValue);
textPrompt = getFromSection(providerConfigRoot, "chat", "textPrompt", AppKeys.TEXT_PROMPT.defaultValue);
imagePrompt = getFromSection(providerConfigRoot, "image", "imagePrompt", AppKeys.IMAGE_PROMPT.defaultValue);
imageSize = getFromSection(providerConfigRoot, "image", "size", AppKeys.IMAGE_SIZE.defaultValue);
listenerIndexer = getFromSection(providerConfigRoot, "embeddings", "listenerIndexer", AppKeys.LISTENER_INDEXER.defaultValue);

configValues = secrets.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

Expand Down Expand Up @@ -321,14 +329,27 @@ public Tuple2<AIModel, Model> resolveModelOrThrow(final String modelName, final

/**
* Returns the raw {@code providerConfig} JSON string, or {@code null} if not set.
* Does not include the separately-stored {@code apiKey} — use for display/redaction only.
*/
public String getProviderConfig() {
return providerConfig;
}

/**
* Returns the SHA-256 hex digest of the {@code providerConfig} JSON, or {@code null} if not set.
* Returns the {@code providerConfig} JSON with the separately-stored {@code apiKey}
* injected into each section that does not already define one.
* Use this when building AI model instances.
*/
public String getResolvedProviderConfig() {
return resolvedProviderConfig;
}

/**
* Returns the SHA-256 hex digest of the resolved provider config (i.e. {@code providerConfig}
* with the top-level {@code apiKey} injected into each section), or {@code "no-config"} if not set.
* Computed once at construction time — safe to use as a cache key on every request.
* Note: rotating the top-level {@code apiKey} without changing {@code providerConfig} will
* produce a different hash, invalidating cached model instances.
*/
public String getProviderConfigHash() {
return providerConfigHash;
Expand All @@ -353,6 +374,45 @@ public boolean isEnabled() {
return true;
}

private static String getFromSection(final JsonNode root, final String section,
final String field, final String defaultValue) {
try {
final JsonNode sectionNode = root.get(section);
if (sectionNode == null || sectionNode.isNull()) {
return defaultValue;
}
final JsonNode fieldNode = sectionNode.get(field);
if (fieldNode == null || fieldNode.isNull()) {
return defaultValue;
}
// Container nodes (object/array) are serialized back to a JSON string (e.g. listenerIndexer)
final String value = fieldNode.isContainerNode() ? fieldNode.toString() : fieldNode.asText();
return StringUtils.isNotBlank(value) ? value : defaultValue;
} catch (final Exception e) {
return defaultValue;
}
}

private static String injectApiKeyIntoSections(final JsonNode root, final String apiKey) {
try {
final ObjectNode copy = root.deepCopy();
for (final String section : new String[]{"chat", "embeddings", "image"}) {
final JsonNode sectionNode = copy.get(section);
if (sectionNode != null && sectionNode.isObject()) {
final ObjectNode sectionObj = (ObjectNode) sectionNode;
final JsonNode existing = sectionObj.get("apiKey");
if (existing == null || existing.isNull() || existing.asText().isBlank()) {
sectionObj.put("apiKey", apiKey);
}
}
}
return copy.toString();
} catch (final Exception e) {
Logger.warn(AppConfig.class, "Failed to inject apiKey into providerConfig: " + e.getMessage());
return root.toString();
}
}

@com.google.common.annotations.VisibleForTesting
static JsonNode parseProviderConfig(final String json) {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dotcms.ai.client.langchain4j;

import com.google.common.annotations.VisibleForTesting;
import com.dotcms.ai.AiKeys;
import com.dotcms.ai.app.AIModelType;
import com.dotcms.ai.app.AppConfig;
Expand Down Expand Up @@ -32,7 +33,6 @@
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.image.ImageModel;
import dev.langchain4j.model.output.FinishReason;
import dev.langchain4j.model.output.Response;
import dev.langchain4j.model.output.TokenUsage;
import io.vavr.Lazy;

Expand All @@ -42,6 +42,7 @@
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
Expand Down Expand Up @@ -132,7 +133,7 @@ public <T extends Serializable> void sendRequest(final AIRequest<T> request, fin
throw new DotAIAppConfigDisabledException("App dotAI config is not enabled — set providerConfig");
}

final String providerConfigJson = appConfig.getProviderConfig();
final String providerConfigJson = appConfig.getResolvedProviderConfig();
final AIModelType type = jsonRequest.getType();
final JSONObject payload = jsonRequest.getPayload();

Expand All @@ -153,46 +154,67 @@ public <T extends Serializable> void sendRequest(final AIRequest<T> request, fin
}

private String executeChatRequest(final String cacheKeyPrefix, final String providerConfigJson, final JSONObject payload) {
final ChatModel model;
try {
model = chatModelCache.get(
cacheKeyPrefix + ":chat",
() -> LangChain4jModelFactory.buildChatModel(parseSection(providerConfigJson, "chat")));
} catch (ExecutionException | UncheckedExecutionException e) {
final Throwable cause = e.getCause() != null ? e.getCause() : e;
throw new IllegalArgumentException("Failed to initialize chat model: " + cause.getMessage(), cause);
}

final ProviderConfig baseConfig = parseSection(providerConfigJson, "chat");
final List<ChatMessage> messages = toMessages(payload.optJSONArray(AiKeys.MESSAGES));
if (messages.isEmpty()) {
throw new IllegalArgumentException("Chat request must contain at least one message");
}

final ChatResponse response = model.chat(
ChatRequest.builder().messages(messages).build());
return toChatResponseJson(response);
return executeWithFallback(cacheKeyPrefix, "chat", baseConfig, chatModelCache,
LangChain4jModelFactory::buildChatModel,
model -> toChatResponseJson(model.chat(ChatRequest.builder().messages(messages).build())));
}

private void executeStreamingChatRequest(final String cacheKeyPrefix,
final String providerConfigJson,
final JSONObject payload,
final OutputStream output) {
final StreamingChatModel model;
try {
model = streamingChatModelCache.get(
cacheKeyPrefix + ":chat:streaming",
() -> LangChain4jModelFactory.buildStreamingChatModel(parseSection(providerConfigJson, "chat")));
} catch (ExecutionException | UncheckedExecutionException e) {
final Throwable cause = e.getCause() != null ? e.getCause() : e;
throw new IllegalArgumentException("Failed to initialize streaming chat model: " + cause.getMessage(), cause);
final ProviderConfig baseConfig = parseSection(providerConfigJson, "chat");
final List<String> models = baseConfig.allModels();
if (models.isEmpty()) {
throw new IllegalArgumentException("No model configured in providerConfig.chat — set 'model'");
}

final List<ChatMessage> messages = toMessages(payload.optJSONArray(AiKeys.MESSAGES));
if (messages.isEmpty()) {
throw new IllegalArgumentException("Chat request must contain at least one message");
}

final StreamingChatModel model = initStreamingModel(cacheKeyPrefix, baseConfig, models);
streamWithModel(model, messages, output);
}

// Fallback is only possible before streaming starts — once bytes are written to output
// we cannot retry. Each init failure is logged immediately; the last exception is
// rethrown only after all configured fallback models have been attempted.
private StreamingChatModel initStreamingModel(
final String cacheKeyPrefix,
final ProviderConfig baseConfig,
final List<String> models) {
RuntimeException lastException = null;
for (final String modelName : models) {
Comment thread
dario-daza marked this conversation as resolved.
try {
final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName);
return streamingChatModelCache.get(
cacheKeyPrefix + ":chat:streaming:" + modelName,
() -> LangChain4jModelFactory.buildStreamingChatModel(modelConfig));
} catch (ExecutionException | UncheckedExecutionException e) {
final Throwable cause = e.getCause() != null ? e.getCause() : e;
lastException = new IllegalArgumentException(
"Failed to initialize streaming model '" + modelName + "': " + cause.getMessage(), cause);
Logger.warn(LangChain4jAIClient.class,
"Streaming model '" + modelName + "' init failed: " + cause.getMessage()
+ (models.size() > 1 ? " — trying next model" : ""));
}
}
throw lastException != null ? lastException
: new IllegalArgumentException("All configured streaming chat models exhausted");
}

private void streamWithModel(final StreamingChatModel model,
final List<ChatMessage> messages,
final OutputStream output) {
final ChatRequest chatRequest = ChatRequest.builder().messages(messages).build();
final long start = System.currentTimeMillis();

final CountDownLatch latch = new CountDownLatch(1);
final AtomicReference<Throwable> error = new AtomicReference<>();
Expand All @@ -206,7 +228,9 @@ public void onPartialResponse(final String token) {
}
try {
output.write(toSseChunk(token).getBytes(StandardCharsets.UTF_8));
output.flush();
} catch (IOException e) {
cancelled.set(true);
error.set(e);
latch.countDown();
}
Expand Down Expand Up @@ -238,6 +262,8 @@ public void onError(final Throwable e) {
"Streaming timed out after " + STREAMING_TIMEOUT_SECONDS + " seconds",
new java.util.concurrent.TimeoutException());
}
Logger.info(LangChain4jAIClient.class,
"Streaming chat completed in " + (System.currentTimeMillis() - start) + "ms");
} catch (InterruptedException e) {
cancelled.set(true);
Thread.currentThread().interrupt();
Expand Down Expand Up @@ -273,35 +299,65 @@ private void writeToOutput(final String responseJson, final OutputStream output)
}

private String executeEmbeddingRequest(final String cacheKeyPrefix, final String providerConfigJson, final JSONObject payload) {
final EmbeddingModel model;
try {
model = embeddingModelCache.get(
cacheKeyPrefix + ":embeddings",
() -> LangChain4jModelFactory.buildEmbeddingModel(parseSection(providerConfigJson, "embeddings")));
} catch (ExecutionException | UncheckedExecutionException e) {
final Throwable cause = e.getCause() != null ? e.getCause() : e;
throw new IllegalArgumentException("Failed to initialize embedding model: " + cause.getMessage(), cause);
}

final ProviderConfig baseConfig = parseSection(providerConfigJson, "embeddings");
final String input = payload.getString(AiKeys.INPUT);
final Response<Embedding> response = model.embed(TextSegment.from(input));
return toEmbeddingResponseJson(response.content());
return executeWithFallback(cacheKeyPrefix, "embeddings", baseConfig, embeddingModelCache,
LangChain4jModelFactory::buildEmbeddingModel,
model -> toEmbeddingResponseJson(model.embed(TextSegment.from(input)).content()));
}

private String executeImageRequest(final String cacheKeyPrefix, final String providerConfigJson, final JSONObject payload) {
final ImageModel model;
try {
model = imageModelCache.get(
cacheKeyPrefix + ":image",
() -> LangChain4jModelFactory.buildImageModel(parseSection(providerConfigJson, "image")));
} catch (ExecutionException | UncheckedExecutionException e) {
final Throwable cause = e.getCause() != null ? e.getCause() : e;
throw new IllegalArgumentException("Failed to initialize image model: " + cause.getMessage(), cause);
}

final ProviderConfig baseConfig = parseSection(providerConfigJson, "image");
final String prompt = payload.getString(AiKeys.PROMPT);
final Response<Image> response = model.generate(prompt);
return toImageResponseJson(response.content());
return executeWithFallback(cacheKeyPrefix, "image", baseConfig, imageModelCache,
LangChain4jModelFactory::buildImageModel,
model -> toImageResponseJson(model.generate(prompt).content()));
}

@VisibleForTesting
<M> String executeWithFallback(
final String cacheKeyPrefix,
final String section,
final ProviderConfig baseConfig,
final Cache<String, M> modelCache,
final Function<ProviderConfig, M> modelBuilder,
final Function<M, String> executor) {
final List<String> models = baseConfig.allModels();
if (models.isEmpty()) {
throw new IllegalArgumentException(
"No model configured in providerConfig." + section + " — set 'model'");
}
// Each failure is logged immediately. The last exception is rethrown only after
// all configured fallback models have been attempted.
RuntimeException lastException = null;
Comment thread
dario-daza marked this conversation as resolved.
for (final String modelName : models) {
try {
final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName);
final M model = modelCache.get(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for instance this method is easier to read

cacheKeyPrefix + ":" + section + ":" + modelName,
() -> modelBuilder.apply(modelConfig));
final long start = System.currentTimeMillis();
final String result = executor.apply(model);
Logger.info(LangChain4jAIClient.class,
section + " model '" + modelName + "' responded in "
+ (System.currentTimeMillis() - start) + "ms");
return result;
} catch (ExecutionException | UncheckedExecutionException e) {
final Throwable cause = e.getCause() != null ? e.getCause() : e;
lastException = new IllegalArgumentException(
"Failed to initialize " + section + " model '" + modelName + "': " + cause.getMessage(), cause);
Logger.warn(LangChain4jAIClient.class,
section + " model '" + modelName + "' init failed: " + cause.getMessage()
+ (models.size() > 1 ? " — trying next model" : ""));
} catch (RuntimeException e) {
lastException = e;
Logger.warn(LangChain4jAIClient.class,
section + " model '" + modelName + "' failed: " + e.getMessage()
+ (models.size() > 1 ? " — trying next model" : ""));
}
}
throw lastException != null ? lastException
: new IllegalArgumentException("All configured " + section + " models exhausted");
}

static List<ChatMessage> toMessages(final JSONArray messagesArray) {
Expand Down Expand Up @@ -395,6 +451,9 @@ static String toImageResponseJson(final Image image) {
}

private static ProviderConfig parseSection(final String providerConfigJson, final String section) {
if (providerConfigJson == null) {
throw new IllegalArgumentException("providerConfig is null — app config is not enabled");
}
try {
final JsonNode root = MAPPER.readTree(providerConfigJson);
final JsonNode sectionNode = root.get(section);
Expand Down
Loading
Loading