From 6b94e4fdf63229e5370997efa00d75819da83aa1 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Tue, 21 Apr 2026 17:57:48 -0300 Subject: [PATCH 01/28] feat(dotAI): consolidate config into single providerConfig JSON with credential merge on save --- .../java/com/dotcms/ai/app/AppConfig.java | 33 +++++- .../dotcms/ai/app/ProviderConfigMerger.java | 99 ++++++++++++++++ .../dotcms/ai/rest/CompletionsResource.java | 93 +++++++++++++++ dotCMS/src/main/resources/apps/dotAI.yml | 107 ++++++------------ .../main/webapp/WEB-INF/openapi/openapi.yaml | 22 ++++ 5 files changed, 276 insertions(+), 78 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java diff --git a/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java b/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java index a46f7ac058f9..ba533b250bd9 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java +++ b/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java @@ -52,6 +52,7 @@ public class AppConfig implements Serializable { private final String listenerIndexer; private final String providerConfig; private final String providerConfigHash; + private final transient JsonNode providerConfigRoot; private final Map configValues; public AppConfig(final String host, final Map secrets) { @@ -67,22 +68,23 @@ public AppConfig(final String host, final Map secrets) { if (StringUtils.isNotBlank(providerConfig)) { providerConfigHash = DigestUtils.sha256Hex(providerConfig); - final JsonNode providerConfigRoot = parseProviderConfig(providerConfig); + providerConfigRoot = parseProviderConfig(providerConfig); 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(); 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)); @@ -353,6 +355,25 @@ 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; + } + } + @com.google.common.annotations.VisibleForTesting static JsonNode parseProviderConfig(final String json) { try { diff --git a/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java b/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java new file mode 100644 index 000000000000..c332f6169b46 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java @@ -0,0 +1,99 @@ +package com.dotcms.ai.app; + +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.util.Logger; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.lang3.StringUtils; + +import java.util.Iterator; +import java.util.Map; + +/** + * Merges a partially-masked {@code providerConfig} JSON with the real stored configuration. + * + *

When a user edits the provider config through the API, they receive a redacted view + * where credential fields ({@code apiKey}, {@code secretAccessKey}, {@code accessKeyId}) + * are replaced with {@value #MASKED}. If they submit that JSON back without changing the + * credential values, this class restores the real credentials from the currently stored config + * before persisting — so credentials are never overwritten with the sentinel value. + * + *

Merge rules: + *

    + *
  • Any string field equal to {@value #MASKED} in the incoming JSON is replaced with + * the corresponding real value from the stored JSON, if present.
  • + *
  • Nested objects (e.g. {@code chat}, {@code embeddings}, {@code image} sections) + * are merged recursively.
  • + *
  • All other fields are taken from the incoming JSON as-is.
  • + *
  • On any parse error the incoming JSON is returned unchanged.
  • + *
+ */ +public class ProviderConfigMerger { + + public static final String MASKED = "*****"; + private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper(); + + private ProviderConfigMerger() {} + + /** + * Returns {@code true} if {@code json} contains at least one field value equal to + * {@value #MASKED} (fast string check, no parsing). + */ + public static boolean containsMasked(final String json) { + return StringUtils.isNotBlank(json) && json.contains("\"" + MASKED + "\""); + } + + /** + * Merges {@code newJson} with {@code storedJson}, replacing any {@value #MASKED} values + * in {@code newJson} with the corresponding real values from {@code storedJson}. + * + * @param newJson incoming JSON, potentially containing {@value #MASKED} sentinels + * @param storedJson currently stored JSON with real credential values; may be blank + * @return merged JSON string, or {@code newJson} unchanged if {@code storedJson} is blank + * or a parse error occurs + */ + public static String merge(final String newJson, final String storedJson) { + if (StringUtils.isBlank(storedJson)) { + return newJson; + } + try { + final JsonNode newRoot = MAPPER.readTree(newJson); + if (!newRoot.isObject()) { + return newJson; + } + final JsonNode storedRoot = MAPPER.readTree(storedJson); + mergeNode((ObjectNode) newRoot, storedRoot); + return MAPPER.writeValueAsString(newRoot); + } catch (final Exception e) { + Logger.warn(ProviderConfigMerger.class, + "Failed to merge providerConfig, using incoming value as-is: " + e.getMessage()); + return newJson; + } + } + + private static void mergeNode(final ObjectNode incoming, final JsonNode stored) { + if (stored == null || !stored.isObject()) { + return; + } + final Iterator> fields = incoming.fields(); + while (fields.hasNext()) { + final Map.Entry entry = fields.next(); + final String key = entry.getKey(); + final JsonNode incomingValue = entry.getValue(); + + if (incomingValue.isTextual() && MASKED.equals(incomingValue.asText())) { + final JsonNode storedValue = stored.get(key); + if (storedValue != null && !storedValue.isNull()) { + incoming.set(key, storedValue); + } + } else if (incomingValue.isObject()) { + final JsonNode storedChild = stored.get(key); + if (storedChild != null && storedChild.isObject()) { + mergeNode((ObjectNode) incomingValue, storedChild); + } + } + } + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index b86a81b61b3c..595c6042d0e6 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -4,10 +4,13 @@ import com.dotcms.ai.app.AppConfig; import com.dotcms.ai.app.AppKeys; import com.dotcms.ai.app.ConfigService; +import com.dotcms.ai.app.ProviderConfigMerger; import com.dotcms.ai.rest.forms.CompletionsForm; import com.dotcms.ai.util.LineReadingOutputStream; import com.dotcms.rest.WebResource; import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotcms.security.apps.Secret; +import com.dotcms.security.apps.Type; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.web.WebAPILocator; @@ -24,13 +27,16 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import io.vavr.Tuple; import org.apache.commons.lang3.StringUtils; import org.glassfish.jersey.server.JSONP; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.POST; +import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.Context; @@ -193,6 +199,93 @@ public final Response getConfig(@Context final HttpServletRequest request, return Response.ok(map).build(); } + /** + * Saves the {@code providerConfig} JSON for the current host. + * + *

Credential fields ({@code apiKey}, {@code secretAccessKey}, {@code accessKeyId}) that + * are set to {@code "*****"} in the request body are automatically replaced with the real + * stored values before persisting — so the user can edit non-credential fields without + * needing to re-enter API keys. + * + *

Requires CMS admin. + * + * @param request the HTTP request (used to resolve the current host) + * @param response the HTTP response + * @param body the full providerConfig JSON, with optional {@code "*****"} sentinels + * @return the saved configuration with credentials redacted + */ + @PUT + @JSONP + @Path("/config") + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation( + operationId = "saveAiConfig", + summary = "Save AI provider configuration", + description = "Saves the providerConfig JSON for the current host. Credential fields set to \"*****\" are preserved from the existing stored configuration. Requires CMS admin.", + tags = {"AI"}, + responses = { + @ApiResponse(responseCode = "200", description = "Configuration saved successfully"), + @ApiResponse(responseCode = "400", description = "Missing or invalid request body"), + @ApiResponse(responseCode = "403", description = "Forbidden - requires CMS admin"), + @ApiResponse(responseCode = "500", description = "Internal server error") + } + ) + public Response saveConfig(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final String body) { + final User user = new WebResource + .InitBuilder(request, response) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .init() + .getUser(); + + if (!user.isAdmin()) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of(AiKeys.ERROR, "Only CMS admins can update the AI configuration")) + .build(); + } + + if (StringUtils.isBlank(body)) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, "Request body is required")) + .build(); + } + + try { + final Host host = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + final AppConfig current = ConfigService.INSTANCE.config(host); + + final String merged = ProviderConfigMerger.containsMasked(body) + ? ProviderConfigMerger.merge(body, current.getProviderConfig()) + : body; + + final Secret secret = Secret.builder() + .withValue(merged) + .withType(Type.STRING) + .withHidden(true) + .build(); + + APILocator.getAppsAPI().saveSecret( + AppKeys.APP_KEY, + Tuple.of(AppKeys.PROVIDER_CONFIG.key, secret), + host, + user); + + return Response.ok(Map.of( + AppKeys.PROVIDER_CONFIG.key, redactCredentials(merged), + AiKeys.CONFIG_HOST, host.getHostname() + )).build(); + + } catch (final Exception e) { + Logger.error(CompletionsResource.class, "Failed to save AI config: " + e.getMessage(), e); + return Response.serverError() + .entity(Map.of(AiKeys.ERROR, "Failed to save configuration: " + e.getMessage())) + .build(); + } + } + private static String redactCredentials(final String json) { try { final JsonNode root = REDACTION_MAPPER.readTree(json); diff --git a/dotCMS/src/main/resources/apps/dotAI.yml b/dotCMS/src/main/resources/apps/dotAI.yml index 3192c882d892..becf35901692 100644 --- a/dotCMS/src/main/resources/apps/dotAI.yml +++ b/dotCMS/src/main/resources/apps/dotAI.yml @@ -3,9 +3,13 @@ iconUrl: "https://static.dotcms.com/assets/icons/apps/chatgpt_logo.svg" allowExtraParameters: true description: | Configuration for dotAI, powered by LangChain4J. Supports multiple AI providers - configured via a single JSON field (Provider Config). Each provider section (chat, embeddings, image) - is declared independently, allowing you to mix providers or models per capability. - We recommend a single configuration on SYSTEM_HOST, or override per site as needed. + (openai, azure_openai, bedrock, vertex_ai) configured via a single JSON field. + Full documentation: https://dev.dotcms.com/docs/dotai + + To view the current config (credentials masked): + GET /api/v1/ai/completions/config + To update (credential fields set to "*****" are preserved automatically): + PUT /api/v1/ai/completions/config params: providerConfig: @@ -14,75 +18,34 @@ params: type: "STRING" label: "Provider Config (JSON)" hint: | - JSON configuration for the AI provider. Each section (chat, embeddings, image) declares its own provider independently. - Example for OpenAI: + Single JSON object that configures all AI capabilities. + Each section (chat, embeddings, image) declares its own provider independently. + Prompts and settings belong inside their respective section. + + Example (OpenAI): { - "chat": { "provider": "openai", "apiKey": "sk-...", "model": "gpt-4o", "maxTokens": 16384, "temperature": 1.0, "maxRetries": 3 }, - "embeddings": { "provider": "openai", "apiKey": "sk-...", "model": "text-embedding-ada-002" }, - "image": { "provider": "openai", "apiKey": "sk-...", "model": "dall-e-3", "size": "1024x1024" } + "chat": { + "provider": "openai", + "apiKey": "sk-...", + "model": "gpt-4o", + "maxTokens": 16384, + "temperature": 1.0, + "maxRetries": 3, + "rolePrompt": "You are dotCMSbot, an AI assistant to help content creators.", + "textPrompt": "Use Descriptive writing style." + }, + "embeddings": { + "provider": "openai", + "apiKey": "sk-...", + "model": "text-embedding-ada-002", + "listenerIndexer": { "default": "blog,news,webPageContent" } + }, + "image": { + "provider": "openai", + "apiKey": "sk-...", + "model": "dall-e-3", + "size": "1792x1024", + "imagePrompt": "Use 16:9 aspect ratio." + } } required: true - rolePrompt: - value: "You are dotCMSbot, and AI assistant to help content creators generate and rewrite content in their content management system." - hidden: false - type: "STRING" - label: "Role Prompt" - hint: "A prompt describing the role (if any) the chatbot will play for the dotCMS user." - required: false - textPrompt: - value: "Use Descriptive writing style." - hidden: false - type: "STRING" - label: "Text Prompt" - hint: "A prompt describing writing style." - required: false - imagePrompt: - value: "Use 16:9 aspect ratio." - hidden: false - type: "STRING" - label: "Image Prompt" - hint: "Aspect ratio to use for image." - required: false - imageSize: - hidden: false - type: "SELECT" - label: "Image size" - hint: "Image size to generate" - required: false - value: - - label: "1792x1024 (Blog Image 3:2)" - value: "1792x1024" - selected: true - - label: "1024x1792 (Blog Image Vertical 2:3)" - value: "1024x1792" - - label: "1024x1024 (Large Square 1:1)" - value: "1024x1024" - - label: "1280x720 (Hero Image 16:9)" - value: "1280x720" - - label: "1200x630 (Image 3:2)" - value: "1200x630" - - label: "630x1200 (Image Vertical 2:3)" - value: "630x1200" - - label: "512x512 (Medium Square 1:1)" - value: "512x512" - - label: "1920x1080 (Background 16:9)" - value: "1920x1080" - - label: "256x256 (Small Square 1:1)" - value: "256x256" - listenerIndexer: - value: "" - hidden: false - type: "STRING" - label: "Auto Index Content Config" - hint: | - A json map that automatically maps indexes->contentTypes and tells dotCMS which content types should be indexed and where, e.g. - ``` - { - "default": "blog,news,webPageContent", - "blogsOnly": "blog.blogcontent" - } - ``` - means that blog, news and webPageContent will be indexed in the `default` index and the blog field `blog.blogcontent` will be - indexed into the `blogsOnly` index. The list of content types is a comma separated list content types and can optionally - include the field that should be indexed when a contentlet is published. All unpublished content will be removed from the index. - required: false diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index e80406cfb37c..361b19cc0def 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -2649,6 +2649,28 @@ paths: summary: Get AI service configuration tags: - AI + put: + description: Saves the providerConfig JSON for the current host. Credential + fields set to "*****" are preserved from the existing stored configuration. + Requires CMS admin. + operationId: saveAiConfig + requestBody: + content: + application/json: + schema: + type: string + responses: + "200": + description: Configuration saved successfully + "400": + description: Missing or invalid request body + "403": + description: Forbidden - requires CMS admin + "500": + description: Internal server error + summary: Save AI provider configuration + tags: + - AI /v1/ai/completions/rawPrompt: post: description: Processes raw prompts directly through the AI service without content From 99944b613d3ec99cf0eb885b2477a2b3bb399169 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Tue, 21 Apr 2026 18:14:37 -0300 Subject: [PATCH 02/28] fix(dotAI): restore original YAML description, remove redundant fields from getConfig response --- .../java/com/dotcms/ai/rest/CompletionsResource.java | 5 ----- dotCMS/src/main/resources/apps/dotAI.yml | 9 +++------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index 595c6042d0e6..3aae8ac7fa1e 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -189,11 +189,6 @@ public final Response getConfig(@Context final HttpServletRequest request, map.put(AppKeys.PROVIDER_CONFIG.key, redactCredentials(providerConfig)); } - map.put(AppKeys.ROLE_PROMPT.key, appConfig.getRolePrompt()); - map.put(AppKeys.TEXT_PROMPT.key, appConfig.getTextPrompt()); - map.put(AppKeys.IMAGE_PROMPT.key, appConfig.getImagePrompt()); - map.put(AppKeys.IMAGE_SIZE.key, appConfig.getImageSize()); - map.put(AppKeys.LISTENER_INDEXER.key, appConfig.getListenerIndexer()); map.put(AppKeys.DEBUG_LOGGING.key, appConfig.getConfig(AppKeys.DEBUG_LOGGING)); return Response.ok(map).build(); diff --git a/dotCMS/src/main/resources/apps/dotAI.yml b/dotCMS/src/main/resources/apps/dotAI.yml index becf35901692..753aa89484e4 100644 --- a/dotCMS/src/main/resources/apps/dotAI.yml +++ b/dotCMS/src/main/resources/apps/dotAI.yml @@ -3,14 +3,11 @@ iconUrl: "https://static.dotcms.com/assets/icons/apps/chatgpt_logo.svg" allowExtraParameters: true description: | Configuration for dotAI, powered by LangChain4J. Supports multiple AI providers - (openai, azure_openai, bedrock, vertex_ai) configured via a single JSON field. + configured via a single JSON field (Provider Config). Each provider section (chat, embeddings, image) + is declared independently, allowing you to mix providers or models per capability. + We recommend a single configuration on SYSTEM_HOST, or override per site as needed. Full documentation: https://dev.dotcms.com/docs/dotai - To view the current config (credentials masked): - GET /api/v1/ai/completions/config - To update (credential fields set to "*****" are preserved automatically): - PUT /api/v1/ai/completions/config - params: providerConfig: value: "" From 12dbb1f591352bfdcc1a7f0a3472ea486b55d234 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Tue, 21 Apr 2026 18:17:03 -0300 Subject: [PATCH 03/28] fix(dotAI): set providerConfig as visible field (hidden: false) --- dotCMS/src/main/resources/apps/dotAI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotCMS/src/main/resources/apps/dotAI.yml b/dotCMS/src/main/resources/apps/dotAI.yml index 753aa89484e4..b5a2fadd953f 100644 --- a/dotCMS/src/main/resources/apps/dotAI.yml +++ b/dotCMS/src/main/resources/apps/dotAI.yml @@ -11,7 +11,7 @@ description: | params: providerConfig: value: "" - hidden: true + hidden: false type: "STRING" label: "Provider Config (JSON)" hint: | From 1302acd3ead8c9fa5c9c09653799af3c766bf11d Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Tue, 21 Apr 2026 18:25:16 -0300 Subject: [PATCH 04/28] fix(dotAI): disable allowExtraParameters to remove Custom Properties section --- dotCMS/src/main/resources/apps/dotAI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotCMS/src/main/resources/apps/dotAI.yml b/dotCMS/src/main/resources/apps/dotAI.yml index b5a2fadd953f..2a4be6548722 100644 --- a/dotCMS/src/main/resources/apps/dotAI.yml +++ b/dotCMS/src/main/resources/apps/dotAI.yml @@ -1,6 +1,6 @@ name: "dotAI" iconUrl: "https://static.dotcms.com/assets/icons/apps/chatgpt_logo.svg" -allowExtraParameters: true +allowExtraParameters: false description: | Configuration for dotAI, powered by LangChain4J. Supports multiple AI providers configured via a single JSON field (Provider Config). Each provider section (chat, embeddings, image) From 580559934197062b35f3a2e048588730db3027ee Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Tue, 21 Apr 2026 18:52:04 -0300 Subject: [PATCH 05/28] temp: revert allowExtraParameters to true for cleanup --- dotCMS/src/main/resources/apps/dotAI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotCMS/src/main/resources/apps/dotAI.yml b/dotCMS/src/main/resources/apps/dotAI.yml index 2a4be6548722..b5a2fadd953f 100644 --- a/dotCMS/src/main/resources/apps/dotAI.yml +++ b/dotCMS/src/main/resources/apps/dotAI.yml @@ -1,6 +1,6 @@ name: "dotAI" iconUrl: "https://static.dotcms.com/assets/icons/apps/chatgpt_logo.svg" -allowExtraParameters: false +allowExtraParameters: true description: | Configuration for dotAI, powered by LangChain4J. Supports multiple AI providers configured via a single JSON field (Provider Config). Each provider section (chat, embeddings, image) From 1f1f4942e03663a849e19f424d7d846d91edce5c Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Tue, 21 Apr 2026 20:13:25 -0300 Subject: [PATCH 06/28] feat(ai): multi-model fallback via comma-separated model field --- .../langchain4j/LangChain4jAIClient.java | 168 ++++++++++++++---- .../ai/client/langchain4j/ProviderConfig.java | 27 +++ 2 files changed, 156 insertions(+), 39 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index 32fae81c14a9..e07e21c05f64 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java @@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicReference; import com.google.common.util.concurrent.UncheckedExecutionException; +import com.dotcms.ai.client.langchain4j.ImmutableProviderConfig; /** * {@link AIClient} implementation backed by LangChain4J. @@ -153,14 +154,10 @@ public void sendRequest(final AIRequest 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 models = baseConfig.allModels(); + if (models.isEmpty()) { + throw new IllegalArgumentException("No model configured in providerConfig.chat — set 'model' or 'models'"); } final List messages = toMessages(payload.optJSONArray(AiKeys.MESSAGES)); @@ -168,23 +165,42 @@ private String executeChatRequest(final String cacheKeyPrefix, final String prov throw new IllegalArgumentException("Chat request must contain at least one message"); } - final ChatResponse response = model.chat( - ChatRequest.builder().messages(messages).build()); - return toChatResponseJson(response); + RuntimeException lastException = null; + for (final String modelName : models) { + try { + final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); + final ChatModel model = chatModelCache.get( + cacheKeyPrefix + ":chat:" + modelName, + () -> LangChain4jModelFactory.buildChatModel(modelConfig)); + final ChatResponse response = model.chat(ChatRequest.builder().messages(messages).build()); + return toChatResponseJson(response); + } catch (ExecutionException | UncheckedExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + lastException = new IllegalArgumentException( + "Failed to initialize chat model '" + modelName + "': " + cause.getMessage(), cause); + Logger.warn(LangChain4jAIClient.class, + "Chat model '" + modelName + "' init failed: " + cause.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } catch (RuntimeException e) { + lastException = e; + Logger.warn(LangChain4jAIClient.class, + "Chat model '" + modelName + "' failed: " + e.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } + } + + throw lastException != null ? lastException + : new IllegalArgumentException("All configured chat models exhausted"); } 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 models = baseConfig.allModels(); + if (models.isEmpty()) { + throw new IllegalArgumentException("No model configured in providerConfig.chat — set 'model' or 'models'"); } final List messages = toMessages(payload.optJSONArray(AiKeys.MESSAGES)); @@ -192,6 +208,38 @@ private void executeStreamingChatRequest(final String cacheKeyPrefix, throw new IllegalArgumentException("Chat request must contain at least one message"); } + // Fallback is only possible before streaming starts (once bytes are written to output + // we cannot retry). Loop through models on initialization failures only. + RuntimeException lastException = null; + for (final String modelName : models) { + final StreamingChatModel model; + try { + final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); + model = 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" : "")); + continue; + } + + // Model initialized — stream from it. No retry after this point. + streamWithModel(model, messages, output); + return; + } + + throw lastException != null ? lastException + : new IllegalArgumentException("All configured streaming chat models exhausted"); + } + + private void streamWithModel(final StreamingChatModel model, + final List messages, + final OutputStream output) { final ChatRequest chatRequest = ChatRequest.builder().messages(messages).build(); final CountDownLatch latch = new CountDownLatch(1); @@ -273,35 +321,77 @@ 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 List models = baseConfig.allModels(); + if (models.isEmpty()) { + throw new IllegalArgumentException("No model configured in providerConfig.embeddings — set 'model' or 'models'"); } final String input = payload.getString(AiKeys.INPUT); - final Response response = model.embed(TextSegment.from(input)); - return toEmbeddingResponseJson(response.content()); + + RuntimeException lastException = null; + for (final String modelName : models) { + try { + final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); + final EmbeddingModel model = embeddingModelCache.get( + cacheKeyPrefix + ":embeddings:" + modelName, + () -> LangChain4jModelFactory.buildEmbeddingModel(modelConfig)); + final Response response = model.embed(TextSegment.from(input)); + return toEmbeddingResponseJson(response.content()); + } catch (ExecutionException | UncheckedExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + lastException = new IllegalArgumentException( + "Failed to initialize embedding model '" + modelName + "': " + cause.getMessage(), cause); + Logger.warn(LangChain4jAIClient.class, + "Embedding model '" + modelName + "' init failed: " + cause.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } catch (RuntimeException e) { + lastException = e; + Logger.warn(LangChain4jAIClient.class, + "Embedding model '" + modelName + "' failed: " + e.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } + } + + throw lastException != null ? lastException + : new IllegalArgumentException("All configured embedding models exhausted"); } 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 List models = baseConfig.allModels(); + if (models.isEmpty()) { + throw new IllegalArgumentException("No model configured in providerConfig.image — set 'model' or 'models'"); } final String prompt = payload.getString(AiKeys.PROMPT); - final Response response = model.generate(prompt); - return toImageResponseJson(response.content()); + + RuntimeException lastException = null; + for (final String modelName : models) { + try { + final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); + final ImageModel model = imageModelCache.get( + cacheKeyPrefix + ":image:" + modelName, + () -> LangChain4jModelFactory.buildImageModel(modelConfig)); + final Response response = model.generate(prompt); + return toImageResponseJson(response.content()); + } catch (ExecutionException | UncheckedExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + lastException = new IllegalArgumentException( + "Failed to initialize image model '" + modelName + "': " + cause.getMessage(), cause); + Logger.warn(LangChain4jAIClient.class, + "Image model '" + modelName + "' init failed: " + cause.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } catch (RuntimeException e) { + lastException = e; + Logger.warn(LangChain4jAIClient.class, + "Image model '" + modelName + "' failed: " + e.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } + } + + throw lastException != null ? lastException + : new IllegalArgumentException("All configured image models exhausted"); } static List toMessages(final JSONArray messagesArray) { diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java index 26445210c6a4..a65d5c032fbf 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java @@ -6,6 +6,9 @@ import org.immutables.value.Value; import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; /** * Immutable representation of a single provider section in the {@code providerConfig} JSON. @@ -53,7 +56,31 @@ public interface ProviderConfig { @Nullable String provider(); + + /** + * Model name(s). Accepts a single name ({@code "gpt-4o"}) or a comma-separated fallback + * list ({@code "gpt-4o,gpt-4o-mini"}). Use {@link #allModels()} to iterate over the list. + */ @Nullable String model(); + + /** + * Returns the ordered list of model names parsed from {@link #model()}. + * If {@code model} is blank or null, returns an empty list. + */ + default List allModels() { + final String m = model(); + if (m == null || m.isBlank()) { + return Collections.emptyList(); + } + final List result = new ArrayList<>(); + for (final String part : m.split("\\s*,\\s*")) { + if (!part.isBlank()) { + result.add(part); + } + } + return result; + } + @Nullable Integer maxTokens(); @Nullable Integer maxCompletionTokens(); @Nullable Double temperature(); From 5cb58a230735072c1316419bdcd705a5264dbb85 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 22 Apr 2026 16:48:38 -0300 Subject: [PATCH 07/28] refactor(ai): remove PUT config endpoint and ProviderConfigMerger --- .../dotcms/ai/app/ProviderConfigMerger.java | 99 ------------------- .../dotcms/ai/rest/CompletionsResource.java | 95 ------------------ .../main/webapp/WEB-INF/openapi/openapi.yaml | 22 ----- 3 files changed, 216 deletions(-) delete mode 100644 dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java diff --git a/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java b/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java deleted file mode 100644 index c332f6169b46..000000000000 --- a/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.dotcms.ai.app; - -import com.dotcms.rest.api.v1.DotObjectMapperProvider; -import com.dotmarketing.util.Logger; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.apache.commons.lang3.StringUtils; - -import java.util.Iterator; -import java.util.Map; - -/** - * Merges a partially-masked {@code providerConfig} JSON with the real stored configuration. - * - *

When a user edits the provider config through the API, they receive a redacted view - * where credential fields ({@code apiKey}, {@code secretAccessKey}, {@code accessKeyId}) - * are replaced with {@value #MASKED}. If they submit that JSON back without changing the - * credential values, this class restores the real credentials from the currently stored config - * before persisting — so credentials are never overwritten with the sentinel value. - * - *

Merge rules: - *

    - *
  • Any string field equal to {@value #MASKED} in the incoming JSON is replaced with - * the corresponding real value from the stored JSON, if present.
  • - *
  • Nested objects (e.g. {@code chat}, {@code embeddings}, {@code image} sections) - * are merged recursively.
  • - *
  • All other fields are taken from the incoming JSON as-is.
  • - *
  • On any parse error the incoming JSON is returned unchanged.
  • - *
- */ -public class ProviderConfigMerger { - - public static final String MASKED = "*****"; - private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper(); - - private ProviderConfigMerger() {} - - /** - * Returns {@code true} if {@code json} contains at least one field value equal to - * {@value #MASKED} (fast string check, no parsing). - */ - public static boolean containsMasked(final String json) { - return StringUtils.isNotBlank(json) && json.contains("\"" + MASKED + "\""); - } - - /** - * Merges {@code newJson} with {@code storedJson}, replacing any {@value #MASKED} values - * in {@code newJson} with the corresponding real values from {@code storedJson}. - * - * @param newJson incoming JSON, potentially containing {@value #MASKED} sentinels - * @param storedJson currently stored JSON with real credential values; may be blank - * @return merged JSON string, or {@code newJson} unchanged if {@code storedJson} is blank - * or a parse error occurs - */ - public static String merge(final String newJson, final String storedJson) { - if (StringUtils.isBlank(storedJson)) { - return newJson; - } - try { - final JsonNode newRoot = MAPPER.readTree(newJson); - if (!newRoot.isObject()) { - return newJson; - } - final JsonNode storedRoot = MAPPER.readTree(storedJson); - mergeNode((ObjectNode) newRoot, storedRoot); - return MAPPER.writeValueAsString(newRoot); - } catch (final Exception e) { - Logger.warn(ProviderConfigMerger.class, - "Failed to merge providerConfig, using incoming value as-is: " + e.getMessage()); - return newJson; - } - } - - private static void mergeNode(final ObjectNode incoming, final JsonNode stored) { - if (stored == null || !stored.isObject()) { - return; - } - final Iterator> fields = incoming.fields(); - while (fields.hasNext()) { - final Map.Entry entry = fields.next(); - final String key = entry.getKey(); - final JsonNode incomingValue = entry.getValue(); - - if (incomingValue.isTextual() && MASKED.equals(incomingValue.asText())) { - final JsonNode storedValue = stored.get(key); - if (storedValue != null && !storedValue.isNull()) { - incoming.set(key, storedValue); - } - } else if (incomingValue.isObject()) { - final JsonNode storedChild = stored.get(key); - if (storedChild != null && storedChild.isObject()) { - mergeNode((ObjectNode) incomingValue, storedChild); - } - } - } - } - -} diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index 3aae8ac7fa1e..b8f1d1ccb037 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -4,13 +4,10 @@ import com.dotcms.ai.app.AppConfig; import com.dotcms.ai.app.AppKeys; import com.dotcms.ai.app.ConfigService; -import com.dotcms.ai.app.ProviderConfigMerger; import com.dotcms.ai.rest.forms.CompletionsForm; import com.dotcms.ai.util.LineReadingOutputStream; import com.dotcms.rest.WebResource; import com.dotcms.rest.api.v1.DotObjectMapperProvider; -import com.dotcms.security.apps.Secret; -import com.dotcms.security.apps.Type; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.business.web.WebAPILocator; @@ -21,22 +18,18 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.liferay.portal.model.User; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import io.vavr.Tuple; import org.apache.commons.lang3.StringUtils; import org.glassfish.jersey.server.JSONP; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.POST; -import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.Context; @@ -46,7 +39,6 @@ import java.io.OutputStream; import java.util.HashMap; import java.util.Iterator; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; @@ -194,93 +186,6 @@ public final Response getConfig(@Context final HttpServletRequest request, return Response.ok(map).build(); } - /** - * Saves the {@code providerConfig} JSON for the current host. - * - *

Credential fields ({@code apiKey}, {@code secretAccessKey}, {@code accessKeyId}) that - * are set to {@code "*****"} in the request body are automatically replaced with the real - * stored values before persisting — so the user can edit non-credential fields without - * needing to re-enter API keys. - * - *

Requires CMS admin. - * - * @param request the HTTP request (used to resolve the current host) - * @param response the HTTP response - * @param body the full providerConfig JSON, with optional {@code "*****"} sentinels - * @return the saved configuration with credentials redacted - */ - @PUT - @JSONP - @Path("/config") - @Consumes(MediaType.APPLICATION_JSON) - @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) - @Operation( - operationId = "saveAiConfig", - summary = "Save AI provider configuration", - description = "Saves the providerConfig JSON for the current host. Credential fields set to \"*****\" are preserved from the existing stored configuration. Requires CMS admin.", - tags = {"AI"}, - responses = { - @ApiResponse(responseCode = "200", description = "Configuration saved successfully"), - @ApiResponse(responseCode = "400", description = "Missing or invalid request body"), - @ApiResponse(responseCode = "403", description = "Forbidden - requires CMS admin"), - @ApiResponse(responseCode = "500", description = "Internal server error") - } - ) - public Response saveConfig(@Context final HttpServletRequest request, - @Context final HttpServletResponse response, - final String body) { - final User user = new WebResource - .InitBuilder(request, response) - .requiredBackendUser(true) - .requiredFrontendUser(false) - .init() - .getUser(); - - if (!user.isAdmin()) { - return Response.status(Response.Status.FORBIDDEN) - .entity(Map.of(AiKeys.ERROR, "Only CMS admins can update the AI configuration")) - .build(); - } - - if (StringUtils.isBlank(body)) { - return Response.status(Response.Status.BAD_REQUEST) - .entity(Map.of(AiKeys.ERROR, "Request body is required")) - .build(); - } - - try { - final Host host = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); - final AppConfig current = ConfigService.INSTANCE.config(host); - - final String merged = ProviderConfigMerger.containsMasked(body) - ? ProviderConfigMerger.merge(body, current.getProviderConfig()) - : body; - - final Secret secret = Secret.builder() - .withValue(merged) - .withType(Type.STRING) - .withHidden(true) - .build(); - - APILocator.getAppsAPI().saveSecret( - AppKeys.APP_KEY, - Tuple.of(AppKeys.PROVIDER_CONFIG.key, secret), - host, - user); - - return Response.ok(Map.of( - AppKeys.PROVIDER_CONFIG.key, redactCredentials(merged), - AiKeys.CONFIG_HOST, host.getHostname() - )).build(); - - } catch (final Exception e) { - Logger.error(CompletionsResource.class, "Failed to save AI config: " + e.getMessage(), e); - return Response.serverError() - .entity(Map.of(AiKeys.ERROR, "Failed to save configuration: " + e.getMessage())) - .build(); - } - } - private static String redactCredentials(final String json) { try { final JsonNode root = REDACTION_MAPPER.readTree(json); diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index 361b19cc0def..e80406cfb37c 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -2649,28 +2649,6 @@ paths: summary: Get AI service configuration tags: - AI - put: - description: Saves the providerConfig JSON for the current host. Credential - fields set to "*****" are preserved from the existing stored configuration. - Requires CMS admin. - operationId: saveAiConfig - requestBody: - content: - application/json: - schema: - type: string - responses: - "200": - description: Configuration saved successfully - "400": - description: Missing or invalid request body - "403": - description: Forbidden - requires CMS admin - "500": - description: Internal server error - summary: Save AI provider configuration - tags: - - AI /v1/ai/completions/rawPrompt: post: description: Processes raw prompts directly through the AI service without content From d010f7f09db144425d9a5c56fc123e30d7447c32 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 22 Apr 2026 17:08:16 -0300 Subject: [PATCH 08/28] refactor(ai): extract executeWithFallback helper in LangChain4jAIClient --- .../langchain4j/LangChain4jAIClient.java | 111 +++++------------- 1 file changed, 31 insertions(+), 80 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index e07e21c05f64..24c38652587e 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java @@ -32,7 +32,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; @@ -42,6 +41,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; @@ -155,42 +155,13 @@ public void sendRequest(final AIRequest request, fin private String executeChatRequest(final String cacheKeyPrefix, final String providerConfigJson, final JSONObject payload) { final ProviderConfig baseConfig = parseSection(providerConfigJson, "chat"); - final List models = baseConfig.allModels(); - if (models.isEmpty()) { - throw new IllegalArgumentException("No model configured in providerConfig.chat — set 'model' or 'models'"); - } - final List messages = toMessages(payload.optJSONArray(AiKeys.MESSAGES)); if (messages.isEmpty()) { throw new IllegalArgumentException("Chat request must contain at least one message"); } - - RuntimeException lastException = null; - for (final String modelName : models) { - try { - final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); - final ChatModel model = chatModelCache.get( - cacheKeyPrefix + ":chat:" + modelName, - () -> LangChain4jModelFactory.buildChatModel(modelConfig)); - final ChatResponse response = model.chat(ChatRequest.builder().messages(messages).build()); - return toChatResponseJson(response); - } catch (ExecutionException | UncheckedExecutionException e) { - final Throwable cause = e.getCause() != null ? e.getCause() : e; - lastException = new IllegalArgumentException( - "Failed to initialize chat model '" + modelName + "': " + cause.getMessage(), cause); - Logger.warn(LangChain4jAIClient.class, - "Chat model '" + modelName + "' init failed: " + cause.getMessage() - + (models.size() > 1 ? " — trying next model" : "")); - } catch (RuntimeException e) { - lastException = e; - Logger.warn(LangChain4jAIClient.class, - "Chat model '" + modelName + "' failed: " + e.getMessage() - + (models.size() > 1 ? " — trying next model" : "")); - } - } - - throw lastException != null ? lastException - : new IllegalArgumentException("All configured chat models exhausted"); + return executeWithFallback(cacheKeyPrefix, "chat", baseConfig, chatModelCache, + LangChain4jModelFactory::buildChatModel, + model -> toChatResponseJson(model.chat(ChatRequest.builder().messages(messages).build()))); } private void executeStreamingChatRequest(final String cacheKeyPrefix, @@ -200,7 +171,7 @@ private void executeStreamingChatRequest(final String cacheKeyPrefix, final ProviderConfig baseConfig = parseSection(providerConfigJson, "chat"); final List models = baseConfig.allModels(); if (models.isEmpty()) { - throw new IllegalArgumentException("No model configured in providerConfig.chat — set 'model' or 'models'"); + throw new IllegalArgumentException("No model configured in providerConfig.chat — set 'model'"); } final List messages = toMessages(payload.optJSONArray(AiKeys.MESSAGES)); @@ -322,76 +293,56 @@ private void writeToOutput(final String responseJson, final OutputStream output) private String executeEmbeddingRequest(final String cacheKeyPrefix, final String providerConfigJson, final JSONObject payload) { final ProviderConfig baseConfig = parseSection(providerConfigJson, "embeddings"); - final List models = baseConfig.allModels(); - if (models.isEmpty()) { - throw new IllegalArgumentException("No model configured in providerConfig.embeddings — set 'model' or 'models'"); - } - final String input = payload.getString(AiKeys.INPUT); - - RuntimeException lastException = null; - for (final String modelName : models) { - try { - final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); - final EmbeddingModel model = embeddingModelCache.get( - cacheKeyPrefix + ":embeddings:" + modelName, - () -> LangChain4jModelFactory.buildEmbeddingModel(modelConfig)); - final Response response = model.embed(TextSegment.from(input)); - return toEmbeddingResponseJson(response.content()); - } catch (ExecutionException | UncheckedExecutionException e) { - final Throwable cause = e.getCause() != null ? e.getCause() : e; - lastException = new IllegalArgumentException( - "Failed to initialize embedding model '" + modelName + "': " + cause.getMessage(), cause); - Logger.warn(LangChain4jAIClient.class, - "Embedding model '" + modelName + "' init failed: " + cause.getMessage() - + (models.size() > 1 ? " — trying next model" : "")); - } catch (RuntimeException e) { - lastException = e; - Logger.warn(LangChain4jAIClient.class, - "Embedding model '" + modelName + "' failed: " + e.getMessage() - + (models.size() > 1 ? " — trying next model" : "")); - } - } - - throw lastException != null ? lastException - : new IllegalArgumentException("All configured embedding models exhausted"); + 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 ProviderConfig baseConfig = parseSection(providerConfigJson, "image"); + final String prompt = payload.getString(AiKeys.PROMPT); + return executeWithFallback(cacheKeyPrefix, "image", baseConfig, imageModelCache, + LangChain4jModelFactory::buildImageModel, + model -> toImageResponseJson(model.generate(prompt).content())); + } + + private String executeWithFallback( + final String cacheKeyPrefix, + final String section, + final ProviderConfig baseConfig, + final Cache modelCache, + final Function modelBuilder, + final Function executor) { final List models = baseConfig.allModels(); if (models.isEmpty()) { - throw new IllegalArgumentException("No model configured in providerConfig.image — set 'model' or 'models'"); + throw new IllegalArgumentException( + "No model configured in providerConfig." + section + " — set 'model'"); } - - final String prompt = payload.getString(AiKeys.PROMPT); - RuntimeException lastException = null; for (final String modelName : models) { try { final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); - final ImageModel model = imageModelCache.get( - cacheKeyPrefix + ":image:" + modelName, - () -> LangChain4jModelFactory.buildImageModel(modelConfig)); - final Response response = model.generate(prompt); - return toImageResponseJson(response.content()); + final M model = modelCache.get( + cacheKeyPrefix + ":" + section + ":" + modelName, + () -> modelBuilder.apply(modelConfig)); + return executor.apply(model); } catch (ExecutionException | UncheckedExecutionException e) { final Throwable cause = e.getCause() != null ? e.getCause() : e; lastException = new IllegalArgumentException( - "Failed to initialize image model '" + modelName + "': " + cause.getMessage(), cause); + "Failed to initialize " + section + " model '" + modelName + "': " + cause.getMessage(), cause); Logger.warn(LangChain4jAIClient.class, - "Image model '" + modelName + "' init failed: " + cause.getMessage() + section + " model '" + modelName + "' init failed: " + cause.getMessage() + (models.size() > 1 ? " — trying next model" : "")); } catch (RuntimeException e) { lastException = e; Logger.warn(LangChain4jAIClient.class, - "Image model '" + modelName + "' failed: " + e.getMessage() + section + " model '" + modelName + "' failed: " + e.getMessage() + (models.size() > 1 ? " — trying next model" : "")); } } - throw lastException != null ? lastException - : new IllegalArgumentException("All configured image models exhausted"); + : new IllegalArgumentException("All configured " + section + " models exhausted"); } static List toMessages(final JSONArray messagesArray) { From 5c73e8756f43839a9be99b37d650a8b44cf77bc6 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 22 Apr 2026 20:14:40 -0300 Subject: [PATCH 09/28] fix(postman): update AI collection for providerConfig consolidation --- .../main/resources/postman/AI.postman_collection.json | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/dotcms-postman/src/main/resources/postman/AI.postman_collection.json b/dotcms-postman/src/main/resources/postman/AI.postman_collection.json index 5417a7d5ed3f..9f008e4447b9 100644 --- a/dotcms-postman/src/main/resources/postman/AI.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/AI.postman_collection.json @@ -60,7 +60,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"providerConfig\": {\n \"value\": \"{\\\"chat\\\":{\\\"provider\\\":\\\"openai\\\",\\\"apiKey\\\":\\\"some-api-key-1a2bc3\\\",\\\"model\\\":\\\"gpt-4o-mini\\\",\\\"maxTokens\\\":16384,\\\"maxRetries\\\":0,\\\"endpoint\\\":\\\"http://wm:8080/\\\"},\\\"embeddings\\\":{\\\"provider\\\":\\\"openai\\\",\\\"apiKey\\\":\\\"some-api-key-1a2bc3\\\",\\\"model\\\":\\\"text-embedding-ada-002\\\",\\\"maxRetries\\\":0,\\\"endpoint\\\":\\\"http://wm:8080/\\\"},\\\"image\\\":{\\\"provider\\\":\\\"openai\\\",\\\"apiKey\\\":\\\"some-api-key-1a2bc3\\\",\\\"model\\\":\\\"dall-e-3\\\",\\\"size\\\":\\\"1024x1024\\\",\\\"maxRetries\\\":0,\\\"endpoint\\\":\\\"http://wm:8080/\\\"}}\"\n },\n \"listenerIndexer\": {\n \"value\": \"{\\\"default\\\":\\\"blog,dotcmsdocumentation,feature,ProductBriefs,news,report.file,builds,casestudy\\\",\\\"documentation\\\":\\\"dotcmsdocumentation\\\"}\"\n }\n}" + "raw": "{\n \"providerConfig\": {\n \"value\": \"{\\\"chat\\\":{\\\"provider\\\":\\\"openai\\\",\\\"apiKey\\\":\\\"some-api-key-1a2bc3\\\",\\\"model\\\":\\\"gpt-4o-mini\\\",\\\"maxTokens\\\":16384,\\\"maxRetries\\\":0,\\\"endpoint\\\":\\\"http://wm:8080/\\\"},\\\"embeddings\\\":{\\\"provider\\\":\\\"openai\\\",\\\"apiKey\\\":\\\"some-api-key-1a2bc3\\\",\\\"model\\\":\\\"text-embedding-ada-002\\\",\\\"maxRetries\\\":0,\\\"endpoint\\\":\\\"http://wm:8080/\\\",\\\"listenerIndexer\\\":{\\\"default\\\":\\\"blog,dotcmsdocumentation,feature,ProductBriefs,news,report.file,builds,casestudy\\\",\\\"documentation\\\":\\\"dotcmsdocumentation\\\"}},\\\"image\\\":{\\\"provider\\\":\\\"openai\\\",\\\"apiKey\\\":\\\"some-api-key-1a2bc3\\\",\\\"model\\\":\\\"dall-e-3\\\",\\\"size\\\":\\\"1024x1024\\\",\\\"maxRetries\\\":0,\\\"endpoint\\\":\\\"http://wm:8080/\\\"}}\"\n }\n}" }, "url": { "raw": "{{serverURL}}/api/v1/apps/dotAI/SYSTEM_HOST", @@ -3328,14 +3328,7 @@ "", "pm.test(\"Response contains properties\", function () {", " pm.expect(jsonData).to.have.property(\"providerConfig\");", - " pm.expect(jsonData).to.have.property(\"rolePrompt\");", - " pm.expect(jsonData).to.have.property(\"textPrompt\");", - " pm.expect(jsonData).to.have.property(\"imagePrompt\");", - " pm.expect(jsonData).to.have.property(\"imageSize\");", - " pm.expect(jsonData).to.have.property(\"listenerIndexer\");", " pm.expect(jsonData[\"com.dotcms.ai.debug.logging\"]).to.equal(\"false\");", - " pm.expect(jsonData.textPrompt).to.include(\"Descriptive writing style\");", - " pm.expect(jsonData.rolePrompt).to.include(\"dotCMSbot\");", "});", "" ], From b6321ca9499d8fa1de56ed3d76bc70211b26812c Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 22 Apr 2026 21:26:16 -0300 Subject: [PATCH 10/28] fix(ai): move listenerIndexer into providerConfig in AiTest setup --- dotcms-integration/src/test/java/com/dotcms/ai/AiTest.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dotcms-integration/src/test/java/com/dotcms/ai/AiTest.java b/dotcms-integration/src/test/java/com/dotcms/ai/AiTest.java index d022006ea510..bf5343bb8375 100644 --- a/dotcms-integration/src/test/java/com/dotcms/ai/AiTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/ai/AiTest.java @@ -42,7 +42,7 @@ static String providerConfigJson(final int port, final String chatModel) { return String.format( "{" + "\"chat\":{\"provider\":\"openai\",\"apiKey\":\"%s\",\"model\":\"%s\",\"endpoint\":\"%s\",\"maxRetries\":0}," + - "\"embeddings\":{\"provider\":\"openai\",\"apiKey\":\"%s\",\"model\":\"%s\",\"endpoint\":\"%s\",\"maxRetries\":0}," + + "\"embeddings\":{\"provider\":\"openai\",\"apiKey\":\"%s\",\"model\":\"%s\",\"endpoint\":\"%s\",\"maxRetries\":0,\"listenerIndexer\":{\"default\":\"blog\"}}," + "\"image\":{\"provider\":\"openai\",\"apiKey\":\"%s\",\"model\":\"%s\",\"endpoint\":\"%s\",\"maxRetries\":0}" + "}", API_KEY, chatModel, endpoint, @@ -55,9 +55,6 @@ static Map aiAppSecretsWithProviderConfig( final AppSecrets appSecrets = new AppSecrets.Builder() .withKey(AppKeys.APP_KEY) .withSecret(AppKeys.PROVIDER_CONFIG.key, providerConfigJson) - .withSecret(AppKeys.LISTENER_INDEXER.key, "{\"default\":\"blog\"}") - .withSecret(AppKeys.COMPLETION_ROLE_PROMPT.key, AppKeys.COMPLETION_ROLE_PROMPT.defaultValue) - .withSecret(AppKeys.COMPLETION_TEXT_PROMPT.key, AppKeys.COMPLETION_TEXT_PROMPT.defaultValue) .build(); APILocator.getAppsAPI().saveSecrets(appSecrets, host, APILocator.systemUser()); await().atMost(5, SECONDS).until(() -> ConfigService.INSTANCE.config(host).isEnabled()); From 74bea415632276a4fe7021c092af83346b0e5709 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 18:47:05 -0300 Subject: [PATCH 11/28] feat(ai): custom UI and PUT endpoint for dotAI provider config --- .../dot-ai-config-detail.component.html | 34 ++++++ .../dot-ai-config-detail.component.scss | 66 +++++++++++ .../dot-ai-config-detail.component.ts | 106 ++++++++++++++++++ .../app/portlets/dot-apps/dot-apps.routes.ts | 9 ++ .../src/lib/dot-ai/dot-ai.service.ts | 15 +++ .../dotcms/ai/app/ProviderConfigMerger.java | 99 ++++++++++++++++ .../dotcms/ai/rest/CompletionsResource.java | 78 +++++++++++++ .../main/webapp/WEB-INF/openapi/openapi.yaml | 22 ++++ 8 files changed, 429 insertions(+) create mode 100644 core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html create mode 100644 core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss create mode 100644 core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts create mode 100644 dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html new file mode 100644 index 000000000000..8b38f4ce16fa --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html @@ -0,0 +1,34 @@ +@if (app) { +

+
+ +
+ {{ app.sites[0].name }} +
+ + +
+
+
+
+
+ +
+
+
+} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss new file mode 100644 index 000000000000..b522bb5b3a09 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss @@ -0,0 +1,66 @@ +@use "../../../../../../../../libs/dotcms-scss/shared/colors"; +@use "../../../../../../../../libs/dotcms-scss/shared/fonts"; +@use "../../../../../../../../libs/dotcms-scss/shared/shadows"; +@use "../../../../../../../../libs/dotcms-scss/shared/spacing"; + +:host { + background: colors.$color-palette-gray-200; + box-shadow: shadows.$shadow-m; + display: flex; + height: 100%; + padding: spacing.$spacing-4; +} + +.dot-ai-config-detail__header { + background-color: colors.$white; + position: sticky; + top: 0; + z-index: 1; +} + +.dot-ai-config-detail__actions button:last-child { + margin-left: spacing.$spacing-1; +} + +.dot-ai-config-detail__body { + flex-grow: 1; +} + +.dot-ai-config-detail__host-name { + border-bottom: 1px solid colors.$color-palette-gray-300; + color: colors.$black; + display: flex; + justify-content: space-between; + font-size: fonts.$font-size-lmd; + font-weight: fonts.$font-weight-semi-bold; + padding: spacing.$spacing-3; + + span { + align-items: center; + display: inline-flex; + } +} + +.dot-ai-config-detail__form-content { + margin: spacing.$spacing-4; + display: flex; + flex-direction: column; + height: 100%; +} + +.dot-ai-config-detail__container { + background-color: colors.$white; + box-shadow: shadows.$shadow-m; + display: flex; + flex-direction: column; + overflow-y: auto; + width: 100%; + font-size: fonts.$font-size-md; +} + +.dot-ai-config-detail__textarea { + font-family: monospace; + font-size: fonts.$font-size-sm; + resize: vertical; + width: 100%; +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts new file mode 100644 index 000000000000..2a5d73563ffa --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts @@ -0,0 +1,106 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; + +import { ButtonModule } from 'primeng/button'; +import { TextareaModule } from 'primeng/textarea'; + +import { map, take } from 'rxjs/operators'; + +import { + DotAiService, + DotMessageDisplayService, + DotMessageService, + DotRouterService +} from '@dotcms/data-access'; +import { DotApp, DotMessageSeverity, DotMessageType } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotAppsConfigurationHeaderComponent } from '../dot-apps-configuration-detail/components/dot-apps-configuration-header/dot-apps-configuration-header.component'; + +@Component({ + selector: 'dot-ai-config-detail', + templateUrl: './dot-ai-config-detail.component.html', + styleUrls: ['./dot-ai-config-detail.component.scss'], + imports: [ + FormsModule, + ButtonModule, + TextareaModule, + DotAppsConfigurationHeaderComponent, + DotMessagePipe + ] +}) +export class DotAiConfigDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private dotAiService = inject(DotAiService); + private dotRouterService = inject(DotRouterService); + private dotMessageDisplayService = inject(DotMessageDisplayService); + private dotMessageService = inject(DotMessageService); + + app: DotApp; + configJson = ''; + saving = false; + + ngOnInit(): void { + this.route.data + .pipe( + map((x) => x?.data), + take(1) + ) + .subscribe((app: DotApp) => { + this.app = app; + }); + + this.dotAiService + .getConfig() + .pipe(take(1)) + .subscribe({ + next: (config) => { + if (config?.providerConfig) { + try { + this.configJson = JSON.stringify( + JSON.parse(config.providerConfig), + null, + 2 + ); + } catch { + this.configJson = config.providerConfig; + } + } + } + }); + } + + onSubmit(): void { + this.saving = true; + this.dotAiService + .saveConfig(this.configJson) + .pipe(take(1)) + .subscribe({ + next: () => { + this.saving = false; + this.dotMessageDisplayService.push({ + life: 3000, + message: this.dotMessageService.get('dot.common.message.saved'), + severity: DotMessageSeverity.SUCCESS, + type: DotMessageType.SIMPLE_MESSAGE + }); + }, + error: (err) => { + this.saving = false; + const detail = + err?.error?.error ?? err?.message ?? 'Failed to save AI configuration'; + this.dotMessageDisplayService.push({ + life: 5000, + message: detail, + severity: DotMessageSeverity.ERROR, + type: DotMessageType.SIMPLE_MESSAGE + }); + } + }); + } + + goToApps(): void { + this.dotRouterService.goToAppsConfiguration(this.app.key); + } +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps.routes.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps.routes.ts index 394b2c363012..795dfa41d033 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { DotAppsService } from '@dotcms/data-access'; +import { DotAiConfigDetailComponent } from './components/dot-ai-config-detail/dot-ai-config-detail.component'; import { DotAppsConfigurationComponent } from './components/dot-apps-configuration/dot-apps-configuration.component'; import { DotAppsConfigurationDetailComponent } from './components/dot-apps-configuration-detail/dot-apps-configuration-detail.component'; import { DotAppsListComponent } from './dot-apps-list/dot-apps-list.component'; @@ -10,6 +11,14 @@ import { DotAppsConfigurationResolver } from './services/dot-apps-configuration- import { DotAppsListResolver } from './services/dot-apps-list-resolver/dot-apps-list-resolver.service'; export const dotAppsRoutes: Routes = [ + { + component: DotAiConfigDetailComponent, + path: 'dotAI/edit/:id', + resolve: { + data: DotAppsConfigurationDetailResolver + }, + providers: [DotAppsService, DotAppsConfigurationDetailResolver] + }, { component: DotAppsConfigurationDetailComponent, path: ':appKey/create/:id', diff --git a/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts b/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts index 59d729019903..de35c32ad00e 100644 --- a/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts +++ b/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts @@ -20,6 +20,11 @@ export const AI_PLUGIN_KEY = { export const API_ENDPOINT = '/api/v1/ai'; export const API_ENDPOINT_FOR_PUBLISH = '/api/v1/workflow/actions/default/fire/PUBLISH'; +export interface DotAiProviderConfig { + providerConfig: string; + configHost: string; +} + const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); @@ -116,6 +121,16 @@ export class DotAiService { ); } + getConfig(): Observable { + return this.#http.get(`${API_ENDPOINT}/completions/config`); + } + + saveConfig(json: string): Observable { + return this.#http.put(`${API_ENDPOINT}/completions/config`, json, { + headers + }); + } + createAndPublishContentlet(aiResponse: DotAIImageResponse): Observable { const { response, tempFileName } = aiResponse; const contentlets: Partial[] = [ diff --git a/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java b/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java new file mode 100644 index 000000000000..c332f6169b46 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java @@ -0,0 +1,99 @@ +package com.dotcms.ai.app; + +import com.dotcms.rest.api.v1.DotObjectMapperProvider; +import com.dotmarketing.util.Logger; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.lang3.StringUtils; + +import java.util.Iterator; +import java.util.Map; + +/** + * Merges a partially-masked {@code providerConfig} JSON with the real stored configuration. + * + *

When a user edits the provider config through the API, they receive a redacted view + * where credential fields ({@code apiKey}, {@code secretAccessKey}, {@code accessKeyId}) + * are replaced with {@value #MASKED}. If they submit that JSON back without changing the + * credential values, this class restores the real credentials from the currently stored config + * before persisting — so credentials are never overwritten with the sentinel value. + * + *

Merge rules: + *

    + *
  • Any string field equal to {@value #MASKED} in the incoming JSON is replaced with + * the corresponding real value from the stored JSON, if present.
  • + *
  • Nested objects (e.g. {@code chat}, {@code embeddings}, {@code image} sections) + * are merged recursively.
  • + *
  • All other fields are taken from the incoming JSON as-is.
  • + *
  • On any parse error the incoming JSON is returned unchanged.
  • + *
+ */ +public class ProviderConfigMerger { + + public static final String MASKED = "*****"; + private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper(); + + private ProviderConfigMerger() {} + + /** + * Returns {@code true} if {@code json} contains at least one field value equal to + * {@value #MASKED} (fast string check, no parsing). + */ + public static boolean containsMasked(final String json) { + return StringUtils.isNotBlank(json) && json.contains("\"" + MASKED + "\""); + } + + /** + * Merges {@code newJson} with {@code storedJson}, replacing any {@value #MASKED} values + * in {@code newJson} with the corresponding real values from {@code storedJson}. + * + * @param newJson incoming JSON, potentially containing {@value #MASKED} sentinels + * @param storedJson currently stored JSON with real credential values; may be blank + * @return merged JSON string, or {@code newJson} unchanged if {@code storedJson} is blank + * or a parse error occurs + */ + public static String merge(final String newJson, final String storedJson) { + if (StringUtils.isBlank(storedJson)) { + return newJson; + } + try { + final JsonNode newRoot = MAPPER.readTree(newJson); + if (!newRoot.isObject()) { + return newJson; + } + final JsonNode storedRoot = MAPPER.readTree(storedJson); + mergeNode((ObjectNode) newRoot, storedRoot); + return MAPPER.writeValueAsString(newRoot); + } catch (final Exception e) { + Logger.warn(ProviderConfigMerger.class, + "Failed to merge providerConfig, using incoming value as-is: " + e.getMessage()); + return newJson; + } + } + + private static void mergeNode(final ObjectNode incoming, final JsonNode stored) { + if (stored == null || !stored.isObject()) { + return; + } + final Iterator> fields = incoming.fields(); + while (fields.hasNext()) { + final Map.Entry entry = fields.next(); + final String key = entry.getKey(); + final JsonNode incomingValue = entry.getValue(); + + if (incomingValue.isTextual() && MASKED.equals(incomingValue.asText())) { + final JsonNode storedValue = stored.get(key); + if (storedValue != null && !storedValue.isNull()) { + incoming.set(key, storedValue); + } + } else if (incomingValue.isObject()) { + final JsonNode storedChild = stored.get(key); + if (storedChild != null && storedChild.isObject()) { + mergeNode((ObjectNode) incomingValue, storedChild); + } + } + } + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index b8f1d1ccb037..e0d6785efb8c 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -4,6 +4,9 @@ import com.dotcms.ai.app.AppConfig; import com.dotcms.ai.app.AppKeys; import com.dotcms.ai.app.ConfigService; +import com.dotcms.ai.app.ProviderConfigMerger; +import com.dotcms.security.apps.Secret; +import com.dotcms.security.apps.Type; import com.dotcms.ai.rest.forms.CompletionsForm; import com.dotcms.ai.util.LineReadingOutputStream; import com.dotcms.rest.WebResource; @@ -28,8 +31,11 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import io.vavr.Tuple; +import javax.ws.rs.Consumes; import javax.ws.rs.GET; import javax.ws.rs.POST; +import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.core.Context; @@ -186,6 +192,78 @@ public final Response getConfig(@Context final HttpServletRequest request, return Response.ok(map).build(); } + @PUT + @JSONP + @Path("/config") + @Consumes(MediaType.APPLICATION_JSON) + @Produces({MediaType.APPLICATION_JSON, "application/javascript"}) + @Operation( + operationId = "saveAiConfig", + summary = "Save AI provider configuration", + description = "Saves the providerConfig JSON for the current host. Credential fields set to \"*****\" are preserved from the existing stored configuration. Requires CMS admin.", + tags = {"AI"}, + responses = { + @ApiResponse(responseCode = "200", description = "Configuration saved successfully"), + @ApiResponse(responseCode = "400", description = "Missing or invalid request body"), + @ApiResponse(responseCode = "403", description = "Forbidden - requires CMS admin"), + @ApiResponse(responseCode = "500", description = "Internal server error") + } + ) + public Response saveConfig(@Context final HttpServletRequest request, + @Context final HttpServletResponse response, + final String body) { + final User user = new WebResource + .InitBuilder(request, response) + .requiredBackendUser(true) + .requiredFrontendUser(false) + .init() + .getUser(); + + if (!user.isAdmin()) { + return Response.status(Response.Status.FORBIDDEN) + .entity(Map.of(AiKeys.ERROR, "Only CMS admins can update the AI configuration")) + .build(); + } + + if (StringUtils.isBlank(body)) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, "Request body is required")) + .build(); + } + + try { + final Host host = WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request); + final AppConfig current = ConfigService.INSTANCE.config(host); + + final String merged = ProviderConfigMerger.containsMasked(body) + ? ProviderConfigMerger.merge(body, current.getProviderConfig()) + : body; + + final Secret secret = Secret.builder() + .withValue(merged) + .withType(Type.STRING) + .withHidden(true) + .build(); + + APILocator.getAppsAPI().saveSecret( + AppKeys.APP_KEY, + Tuple.of(AppKeys.PROVIDER_CONFIG.key, secret), + host, + user); + + return Response.ok(Map.of( + AppKeys.PROVIDER_CONFIG.key, redactCredentials(merged), + AiKeys.CONFIG_HOST, host.getHostname() + )).build(); + + } catch (final Exception e) { + Logger.error(CompletionsResource.class, "Failed to save AI config: " + e.getMessage(), e); + return Response.serverError() + .entity(Map.of(AiKeys.ERROR, "Failed to save configuration: " + e.getMessage())) + .build(); + } + } + private static String redactCredentials(final String json) { try { final JsonNode root = REDACTION_MAPPER.readTree(json); diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index e80406cfb37c..361b19cc0def 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -2649,6 +2649,28 @@ paths: summary: Get AI service configuration tags: - AI + put: + description: Saves the providerConfig JSON for the current host. Credential + fields set to "*****" are preserved from the existing stored configuration. + Requires CMS admin. + operationId: saveAiConfig + requestBody: + content: + application/json: + schema: + type: string + responses: + "200": + description: Configuration saved successfully + "400": + description: Missing or invalid request body + "403": + description: Forbidden - requires CMS admin + "500": + description: Internal server error + summary: Save AI provider configuration + tags: + - AI /v1/ai/completions/rawPrompt: post: description: Processes raw prompts directly through the AI service without content From f7d63b95e8f4ec7546a85ab2eb26a0ebf585d458 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 15:27:49 -0300 Subject: [PATCH 12/28] refactor(ai): address PR review comments on LangChain4jAIClient and ProviderConfig --- .../langchain4j/LangChain4jAIClient.java | 34 +++++++++++++------ .../langchain4j/LangChain4jModelFactory.java | 10 +++--- .../ai/client/langchain4j/ProviderConfig.java | 28 ++++++++------- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index 24c38652587e..20862c62ca59 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java @@ -179,14 +179,22 @@ private void executeStreamingChatRequest(final String cacheKeyPrefix, throw new IllegalArgumentException("Chat request must contain at least one message"); } - // Fallback is only possible before streaming starts (once bytes are written to output - // we cannot retry). Loop through models on initialization failures only. + 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 models) { RuntimeException lastException = null; for (final String modelName : models) { - final StreamingChatModel model; try { final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); - model = streamingChatModelCache.get( + return streamingChatModelCache.get( cacheKeyPrefix + ":chat:streaming:" + modelName, () -> LangChain4jModelFactory.buildStreamingChatModel(modelConfig)); } catch (ExecutionException | UncheckedExecutionException e) { @@ -196,14 +204,8 @@ private void executeStreamingChatRequest(final String cacheKeyPrefix, Logger.warn(LangChain4jAIClient.class, "Streaming model '" + modelName + "' init failed: " + cause.getMessage() + (models.size() > 1 ? " — trying next model" : "")); - continue; } - - // Model initialized — stream from it. No retry after this point. - streamWithModel(model, messages, output); - return; } - throw lastException != null ? lastException : new IllegalArgumentException("All configured streaming chat models exhausted"); } @@ -212,6 +214,7 @@ private void streamWithModel(final StreamingChatModel model, final List 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 error = new AtomicReference<>(); @@ -257,6 +260,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(); @@ -319,6 +324,8 @@ private String executeWithFallback( 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; for (final String modelName : models) { try { @@ -326,7 +333,12 @@ private String executeWithFallback( final M model = modelCache.get( cacheKeyPrefix + ":" + section + ":" + modelName, () -> modelBuilder.apply(modelConfig)); - return executor.apply(model); + 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( diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java index 8effc93212c8..3fab5cb584b5 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java @@ -77,7 +77,7 @@ private static T build(final ProviderConfig config, if (config == null || config.provider() == null) { throw new IllegalArgumentException("ProviderConfig or provider name is null for model type: " + modelType); } - requireNonBlank(config.model(), "model", modelType); + requireNonBlank(config.model().orElse(null), "model", modelType); switch (config.provider().toLowerCase()) { case "openai": validateOpenAi(config, modelType); @@ -113,7 +113,7 @@ private static void applyCommonConfig(final ProviderConfig config, private static ChatModel buildOpenAiChatModel(final ProviderConfig config) { final OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder() .apiKey(config.apiKey()) - .modelName(config.model()); + .modelName(config.model().orElse(null)); applyCommonConfig(config, builder::baseUrl, builder::maxRetries, builder::timeout); if (config.temperature() != null) { builder.temperature(config.temperature()); @@ -129,7 +129,7 @@ private static ChatModel buildOpenAiChatModel(final ProviderConfig config) { private static StreamingChatModel buildOpenAiStreamingChatModel(final ProviderConfig config) { final OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder builder = OpenAiStreamingChatModel.builder() .apiKey(config.apiKey()) - .modelName(config.model()); + .modelName(config.model().orElse(null)); applyCommonConfig(config, builder::baseUrl, ignored -> {}, builder::timeout); if (config.temperature() != null) builder.temperature(config.temperature()); if (config.maxCompletionTokens() != null) { @@ -143,7 +143,7 @@ private static StreamingChatModel buildOpenAiStreamingChatModel(final ProviderCo private static EmbeddingModel buildOpenAiEmbeddingModel(final ProviderConfig config) { final OpenAiEmbeddingModel.OpenAiEmbeddingModelBuilder builder = OpenAiEmbeddingModel.builder() .apiKey(config.apiKey()) - .modelName(config.model()); + .modelName(config.model().orElse(null)); applyCommonConfig(config, builder::baseUrl, builder::maxRetries, builder::timeout); if (config.dimensions() != null) { builder.dimensions(config.dimensions()); @@ -154,7 +154,7 @@ private static EmbeddingModel buildOpenAiEmbeddingModel(final ProviderConfig con private static ImageModel buildOpenAiImageModel(final ProviderConfig config) { final OpenAiImageModel.OpenAiImageModelBuilder builder = OpenAiImageModel.builder() .apiKey(config.apiKey()) - .modelName(config.model()); + .modelName(config.model().orElse(null)); applyCommonConfig(config, builder::baseUrl, builder::maxRetries, builder::timeout); if (config.size() != null) { builder.size(config.size()); diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java index a65d5c032fbf..c5d566848427 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; /** * Immutable representation of a single provider section in the {@code providerConfig} JSON. @@ -61,24 +62,25 @@ public interface ProviderConfig { * Model name(s). Accepts a single name ({@code "gpt-4o"}) or a comma-separated fallback * list ({@code "gpt-4o,gpt-4o-mini"}). Use {@link #allModels()} to iterate over the list. */ - @Nullable String model(); + Optional model(); /** * Returns the ordered list of model names parsed from {@link #model()}. - * If {@code model} is blank or null, returns an empty list. + * If {@code model} is absent or blank, returns an empty list. */ default List allModels() { - final String m = model(); - if (m == null || m.isBlank()) { - return Collections.emptyList(); - } - final List result = new ArrayList<>(); - for (final String part : m.split("\\s*,\\s*")) { - if (!part.isBlank()) { - result.add(part); - } - } - return result; + return model() + .filter(m -> !m.isBlank()) + .map(m -> { + final List result = new ArrayList<>(); + for (final String part : m.split("\\s*,\\s*")) { + if (!part.isBlank()) { + result.add(part); + } + } + return result; + }) + .orElseGet(Collections::emptyList); } @Nullable Integer maxTokens(); From 4bfd48a7d5286c40511c8ff6c438048f2594ea68 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 15:37:17 -0300 Subject: [PATCH 13/28] feat(ai): auto-route maxTokens to max_completion_tokens for OpenAI reasoning models --- .../langchain4j/LangChain4jModelFactory.java | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java index 3fab5cb584b5..54a75fd1e38a 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java @@ -101,6 +101,29 @@ private static void requireNonBlank(final String value, final String field, fina // ── OpenAI builders ─────────────────────────────────────────────────────── + /** + * OpenAI reasoning models (o1, o3, o4-mini, etc.) require {@code max_completion_tokens} + * instead of {@code max_tokens}. Given a single user-facing {@code maxTokens} field, + * this method routes to the correct builder parameter automatically. + */ + private static void applyOpenAiTokenLimit( + final ProviderConfig config, + final Consumer maxTokensFn, + final Consumer maxCompletionTokensFn) { + final Integer tokens = config.maxCompletionTokens() != null + ? config.maxCompletionTokens() + : config.maxTokens(); + if (tokens == null) { + return; + } + final String model = config.model().orElse(""); + if (model.matches("o[0-9].*")) { + maxCompletionTokensFn.accept(tokens); + } else { + maxTokensFn.accept(tokens); + } + } + private static void applyCommonConfig(final ProviderConfig config, final Consumer baseUrlFn, final Consumer retriesFn, @@ -118,11 +141,7 @@ private static ChatModel buildOpenAiChatModel(final ProviderConfig config) { if (config.temperature() != null) { builder.temperature(config.temperature()); } - if (config.maxCompletionTokens() != null) { - builder.maxCompletionTokens(config.maxCompletionTokens()); - } else if (config.maxTokens() != null) { - builder.maxTokens(config.maxTokens()); - } + applyOpenAiTokenLimit(config, builder::maxTokens, builder::maxCompletionTokens); return builder.build(); } @@ -132,11 +151,7 @@ private static StreamingChatModel buildOpenAiStreamingChatModel(final ProviderCo .modelName(config.model().orElse(null)); applyCommonConfig(config, builder::baseUrl, ignored -> {}, builder::timeout); if (config.temperature() != null) builder.temperature(config.temperature()); - if (config.maxCompletionTokens() != null) { - builder.maxCompletionTokens(config.maxCompletionTokens()); - } else if (config.maxTokens() != null) { - builder.maxTokens(config.maxTokens()); - } + applyOpenAiTokenLimit(config, builder::maxTokens, builder::maxCompletionTokens); return builder.build(); } From b7451fac1a9f2c9b466b99fff5196c130b7f96d6 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 16:07:37 -0300 Subject: [PATCH 14/28] revert(ai): keep model() as @Nullable String in ProviderConfig --- .../langchain4j/LangChain4jModelFactory.java | 12 ++++---- .../ai/client/langchain4j/ProviderConfig.java | 28 +++++++++---------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java index 54a75fd1e38a..1b86d24e0a4d 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java @@ -77,7 +77,7 @@ private static T build(final ProviderConfig config, if (config == null || config.provider() == null) { throw new IllegalArgumentException("ProviderConfig or provider name is null for model type: " + modelType); } - requireNonBlank(config.model().orElse(null), "model", modelType); + requireNonBlank(config.model(), "model", modelType); switch (config.provider().toLowerCase()) { case "openai": validateOpenAi(config, modelType); @@ -116,7 +116,7 @@ private static void applyOpenAiTokenLimit( if (tokens == null) { return; } - final String model = config.model().orElse(""); + final String model = config.model() != null ? config.model() : ""; if (model.matches("o[0-9].*")) { maxCompletionTokensFn.accept(tokens); } else { @@ -136,7 +136,7 @@ private static void applyCommonConfig(final ProviderConfig config, private static ChatModel buildOpenAiChatModel(final ProviderConfig config) { final OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder() .apiKey(config.apiKey()) - .modelName(config.model().orElse(null)); + .modelName(config.model()); applyCommonConfig(config, builder::baseUrl, builder::maxRetries, builder::timeout); if (config.temperature() != null) { builder.temperature(config.temperature()); @@ -148,7 +148,7 @@ private static ChatModel buildOpenAiChatModel(final ProviderConfig config) { private static StreamingChatModel buildOpenAiStreamingChatModel(final ProviderConfig config) { final OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder builder = OpenAiStreamingChatModel.builder() .apiKey(config.apiKey()) - .modelName(config.model().orElse(null)); + .modelName(config.model()); applyCommonConfig(config, builder::baseUrl, ignored -> {}, builder::timeout); if (config.temperature() != null) builder.temperature(config.temperature()); applyOpenAiTokenLimit(config, builder::maxTokens, builder::maxCompletionTokens); @@ -158,7 +158,7 @@ private static StreamingChatModel buildOpenAiStreamingChatModel(final ProviderCo private static EmbeddingModel buildOpenAiEmbeddingModel(final ProviderConfig config) { final OpenAiEmbeddingModel.OpenAiEmbeddingModelBuilder builder = OpenAiEmbeddingModel.builder() .apiKey(config.apiKey()) - .modelName(config.model().orElse(null)); + .modelName(config.model()); applyCommonConfig(config, builder::baseUrl, builder::maxRetries, builder::timeout); if (config.dimensions() != null) { builder.dimensions(config.dimensions()); @@ -169,7 +169,7 @@ private static EmbeddingModel buildOpenAiEmbeddingModel(final ProviderConfig con private static ImageModel buildOpenAiImageModel(final ProviderConfig config) { final OpenAiImageModel.OpenAiImageModelBuilder builder = OpenAiImageModel.builder() .apiKey(config.apiKey()) - .modelName(config.model().orElse(null)); + .modelName(config.model()); applyCommonConfig(config, builder::baseUrl, builder::maxRetries, builder::timeout); if (config.size() != null) { builder.size(config.size()); diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java index c5d566848427..a65d5c032fbf 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java @@ -9,7 +9,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.Optional; /** * Immutable representation of a single provider section in the {@code providerConfig} JSON. @@ -62,25 +61,24 @@ public interface ProviderConfig { * Model name(s). Accepts a single name ({@code "gpt-4o"}) or a comma-separated fallback * list ({@code "gpt-4o,gpt-4o-mini"}). Use {@link #allModels()} to iterate over the list. */ - Optional model(); + @Nullable String model(); /** * Returns the ordered list of model names parsed from {@link #model()}. - * If {@code model} is absent or blank, returns an empty list. + * If {@code model} is blank or null, returns an empty list. */ default List allModels() { - return model() - .filter(m -> !m.isBlank()) - .map(m -> { - final List result = new ArrayList<>(); - for (final String part : m.split("\\s*,\\s*")) { - if (!part.isBlank()) { - result.add(part); - } - } - return result; - }) - .orElseGet(Collections::emptyList); + final String m = model(); + if (m == null || m.isBlank()) { + return Collections.emptyList(); + } + final List result = new ArrayList<>(); + for (final String part : m.split("\\s*,\\s*")) { + if (!part.isBlank()) { + result.add(part); + } + } + return result; } @Nullable Integer maxTokens(); From 3711fd10b11895b65a1dffd95034eb5fee530479 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 16:21:54 -0300 Subject: [PATCH 15/28] fix(ai): flush SSE chunks, cancelled flag on IOException, maxRetries warn, providerConfig not required --- .../dotcms/ai/client/langchain4j/LangChain4jAIClient.java | 2 ++ .../ai/client/langchain4j/LangChain4jModelFactory.java | 6 +++++- dotCMS/src/main/resources/apps/dotAI.yml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index 20862c62ca59..8da2771d1791 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java @@ -228,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(); } diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java index 1b86d24e0a4d..f52ccd6aaf9b 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jModelFactory.java @@ -1,5 +1,6 @@ package com.dotcms.ai.client.langchain4j; +import com.dotmarketing.util.Logger; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.model.embedding.EmbeddingModel; @@ -149,7 +150,10 @@ private static StreamingChatModel buildOpenAiStreamingChatModel(final ProviderCo final OpenAiStreamingChatModel.OpenAiStreamingChatModelBuilder builder = OpenAiStreamingChatModel.builder() .apiKey(config.apiKey()) .modelName(config.model()); - applyCommonConfig(config, builder::baseUrl, ignored -> {}, builder::timeout); + applyCommonConfig(config, builder::baseUrl, + ignored -> Logger.warn(LangChain4jModelFactory.class, + "maxRetries is not supported by OpenAiStreamingChatModel and will be ignored"), + builder::timeout); if (config.temperature() != null) builder.temperature(config.temperature()); applyOpenAiTokenLimit(config, builder::maxTokens, builder::maxCompletionTokens); return builder.build(); diff --git a/dotCMS/src/main/resources/apps/dotAI.yml b/dotCMS/src/main/resources/apps/dotAI.yml index b5a2fadd953f..d78d9bd9ef11 100644 --- a/dotCMS/src/main/resources/apps/dotAI.yml +++ b/dotCMS/src/main/resources/apps/dotAI.yml @@ -45,4 +45,4 @@ params: "imagePrompt": "Use 16:9 aspect ratio." } } - required: true + required: false From a6dae0d4c2c5e4df8540e8666effe93c971783cd Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 17:31:10 -0300 Subject: [PATCH 16/28] fix(ai): null check in parseSection, deepCopy in injectApiKeyIntoSections --- .../com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index 8da2771d1791..9b20769848fa 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java @@ -450,6 +450,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); From fc368f15e67c6f951b435f54027a36ed3993d409 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 20:39:11 -0300 Subject: [PATCH 17/28] feat(ai): two-column UI, fix resolver, rendering bug, dotAI.yml description --- .../dot-ai-config-detail-resolver.service.ts | 22 +++++++++++ .../dot-ai-config-detail.component.html | 21 +++++++--- .../dot-ai-config-detail.component.scss | 39 +++++++++++++++++-- .../dot-ai-config-detail.component.ts | 31 ++++++++++++++- .../app/portlets/dot-apps/dot-apps.routes.ts | 5 ++- dotCMS/src/main/resources/apps/dotAI.yml | 7 ++-- 6 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts new file mode 100644 index 000000000000..eb15503783e3 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts @@ -0,0 +1,22 @@ +import { Observable } from 'rxjs'; + +import { Injectable, inject } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; + +import { take } from 'rxjs/operators'; + +import { DotAppsService } from '@dotcms/data-access'; +import { DotApp } from '@dotcms/dotcms-models'; + +const DOT_AI_APP_KEY = 'dotAI'; + +@Injectable() +export class DotAiConfigDetailResolver implements Resolve { + private dotAppsService = inject(DotAppsService); + + resolve(route: ActivatedRouteSnapshot): Observable { + const id = route.paramMap.get('id'); + + return this.dotAppsService.getConfiguration(DOT_AI_APP_KEY, id).pipe(take(1)); + } +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html index 8b38f4ce16fa..42a83cfeae6f 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html @@ -22,12 +22,21 @@
- +
+
+

Provider Config

+ +
+
+

Example JSON

+
{{ exampleJson }}
+
+
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss index b522bb5b3a09..9b6c7b1b4334 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss @@ -24,6 +24,7 @@ .dot-ai-config-detail__body { flex-grow: 1; + overflow-y: auto; } .dot-ai-config-detail__host-name { @@ -43,9 +44,6 @@ .dot-ai-config-detail__form-content { margin: spacing.$spacing-4; - display: flex; - flex-direction: column; - height: 100%; } .dot-ai-config-detail__container { @@ -58,9 +56,44 @@ font-size: fonts.$font-size-md; } +.dot-ai-config-detail__columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: spacing.$spacing-4; + align-items: start; +} + +.dot-ai-config-detail__column { + display: flex; + flex-direction: column; + gap: spacing.$spacing-2; +} + +.dot-ai-config-detail__column-title { + color: colors.$black; + font-size: fonts.$font-size-md; + font-weight: fonts.$font-weight-semi-bold; + margin: 0; +} + .dot-ai-config-detail__textarea { font-family: monospace; font-size: fonts.$font-size-sm; resize: vertical; width: 100%; } + +.dot-ai-config-detail__example { + background-color: colors.$color-palette-gray-100; + border: 1px solid colors.$color-palette-gray-300; + border-radius: 4px; + color: colors.$color-palette-gray-700; + font-family: monospace; + font-size: fonts.$font-size-sm; + line-height: 1.5; + margin: 0; + overflow-x: auto; + overflow-y: auto; + padding: spacing.$spacing-3; + white-space: pre; +} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts index 2a5d73563ffa..4e4f36e49f24 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; @@ -18,6 +18,32 @@ import { DotMessagePipe } from '@dotcms/ui'; import { DotAppsConfigurationHeaderComponent } from '../dot-apps-configuration-detail/components/dot-apps-configuration-header/dot-apps-configuration-header.component'; +const EXAMPLE_CONFIG = { + chat: { + provider: 'openai', + apiKey: 'sk-...', + model: 'gpt-4o', + maxTokens: 16384, + temperature: 1.0, + maxRetries: 3, + rolePrompt: 'You are dotCMSbot, an AI assistant to help content creators.', + textPrompt: 'Use Descriptive writing style.' + }, + embeddings: { + provider: 'openai', + apiKey: 'sk-...', + model: 'text-embedding-ada-002', + listenerIndexer: { default: 'blog,news,webPageContent' } + }, + image: { + provider: 'openai', + apiKey: 'sk-...', + model: 'dall-e-3', + size: '1792x1024', + imagePrompt: 'Use 16:9 aspect ratio.' + } +}; + @Component({ selector: 'dot-ai-config-detail', templateUrl: './dot-ai-config-detail.component.html', @@ -36,10 +62,12 @@ export class DotAiConfigDetailComponent implements OnInit { private dotRouterService = inject(DotRouterService); private dotMessageDisplayService = inject(DotMessageDisplayService); private dotMessageService = inject(DotMessageService); + private cdr = inject(ChangeDetectorRef); app: DotApp; configJson = ''; saving = false; + readonly exampleJson = JSON.stringify(EXAMPLE_CONFIG, null, 2); ngOnInit(): void { this.route.data @@ -66,6 +94,7 @@ export class DotAiConfigDetailComponent implements OnInit { } catch { this.configJson = config.providerConfig; } + this.cdr.detectChanges(); } } }); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps.routes.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps.routes.ts index 795dfa41d033..6751168dc737 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { DotAppsService } from '@dotcms/data-access'; +import { DotAiConfigDetailResolver } from './components/dot-ai-config-detail/dot-ai-config-detail-resolver.service'; import { DotAiConfigDetailComponent } from './components/dot-ai-config-detail/dot-ai-config-detail.component'; import { DotAppsConfigurationComponent } from './components/dot-apps-configuration/dot-apps-configuration.component'; import { DotAppsConfigurationDetailComponent } from './components/dot-apps-configuration-detail/dot-apps-configuration-detail.component'; @@ -15,9 +16,9 @@ export const dotAppsRoutes: Routes = [ component: DotAiConfigDetailComponent, path: 'dotAI/edit/:id', resolve: { - data: DotAppsConfigurationDetailResolver + data: DotAiConfigDetailResolver }, - providers: [DotAppsService, DotAppsConfigurationDetailResolver] + providers: [DotAppsService, DotAiConfigDetailResolver] }, { component: DotAppsConfigurationDetailComponent, diff --git a/dotCMS/src/main/resources/apps/dotAI.yml b/dotCMS/src/main/resources/apps/dotAI.yml index d78d9bd9ef11..e077a5a601d3 100644 --- a/dotCMS/src/main/resources/apps/dotAI.yml +++ b/dotCMS/src/main/resources/apps/dotAI.yml @@ -2,10 +2,9 @@ name: "dotAI" iconUrl: "https://static.dotcms.com/assets/icons/apps/chatgpt_logo.svg" allowExtraParameters: true description: | - Configuration for dotAI, powered by LangChain4J. Supports multiple AI providers - configured via a single JSON field (Provider Config). Each provider section (chat, embeddings, image) - is declared independently, allowing you to mix providers or models per capability. - We recommend a single configuration on SYSTEM_HOST, or override per site as needed. + Credentials and options for using OpenAI/ChatGPT with dotCMS. Each provider section + (chat, embeddings, image) is declared independently. We recommend you have a single config + for all your sites, by placing all config on the SYSTEM_HOST or provide a configuration per site. Full documentation: https://dev.dotcms.com/docs/dotai params: From 43d1a6563f018f7bd956d86f60f6eccfafe28539 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 21:04:09 -0300 Subject: [PATCH 18/28] fix(ai): immutable allModels(), fallback tests, self-import, javadoc, YAML hint --- .../java/com/dotcms/ai/app/AppConfig.java | 2 +- .../langchain4j/LangChain4jAIClient.java | 5 +- .../ai/client/langchain4j/ProviderConfig.java | 2 +- .../langchain4j/LangChain4jAIClientTest.java | 106 ++++++++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java b/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java index ba533b250bd9..369bbfad59b4 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java +++ b/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java @@ -329,7 +329,7 @@ public String getProviderConfig() { } /** - * Returns the SHA-256 hex digest of the {@code providerConfig} JSON, or {@code null} if not set. + * Returns the SHA-256 hex digest of the {@code providerConfig} JSON, or {@code "no-config"} if not set. * Computed once at construction time — safe to use as a cache key on every request. */ public String getProviderConfigHash() { diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index 9b20769848fa..6d41943d7aeb 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java @@ -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; @@ -48,7 +49,6 @@ import java.util.concurrent.atomic.AtomicReference; import com.google.common.util.concurrent.UncheckedExecutionException; -import com.dotcms.ai.client.langchain4j.ImmutableProviderConfig; /** * {@link AIClient} implementation backed by LangChain4J. @@ -314,7 +314,8 @@ private String executeImageRequest(final String cacheKeyPrefix, final String pro model -> toImageResponseJson(model.generate(prompt).content())); } - private String executeWithFallback( + @VisibleForTesting + String executeWithFallback( final String cacheKeyPrefix, final String section, final ProviderConfig baseConfig, diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java index a65d5c032fbf..8746541e9019 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java +++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConfig.java @@ -78,7 +78,7 @@ default List allModels() { result.add(part); } } - return result; + return List.copyOf(result); } @Nullable Integer maxTokens(); diff --git a/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClientTest.java b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClientTest.java index 32d735fc3b07..e626c648eece 100644 --- a/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClientTest.java +++ b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClientTest.java @@ -6,6 +6,8 @@ import com.dotcms.ai.exception.DotAIAppConfigDisabledException; import com.dotmarketing.util.json.JSONArray; import com.dotmarketing.util.json.JSONObject; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; import dev.langchain4j.data.embedding.Embedding; import dev.langchain4j.data.image.Image; import dev.langchain4j.data.message.AiMessage; @@ -19,6 +21,7 @@ import java.net.URI; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; @@ -147,6 +150,109 @@ public void test_toImageResponseJson_nullUrl_returnsEmptyString() { assertEquals("", url); } + // ------------------------------------------------------------------------- + // executeWithFallback — multi-model fallback behaviour + // ------------------------------------------------------------------------- + + private static ProviderConfig configWithModels(final String models) { + return ImmutableProviderConfig.builder() + .provider("openai") + .apiKey("sk-test") + .model(models) + .build(); + } + + private static Cache freshCache() { + return CacheBuilder.newBuilder().build(); + } + + @Test + public void test_executeWithFallback_initFailure_fallsBackToNextModel() { + final ProviderConfig config = configWithModels("bad-model,good-model"); + final Cache cache = freshCache(); + + // builder throws for "bad-model", succeeds for "good-model" + final String result = LangChain4jAIClient.get().executeWithFallback( + "test", "chat", config, cache, + cfg -> { + if ("bad-model".equals(cfg.model())) { + throw new IllegalStateException("simulated init failure"); + } + return "good-instance"; + }, + model -> "response-from-" + model); + + assertEquals("response-from-good-instance", result); + } + + @Test + public void test_executeWithFallback_runtimeFailure_fallsBackToNextModel() { + final ProviderConfig config = configWithModels("bad-model,good-model"); + final Cache cache = freshCache(); + + // builder always succeeds, executor throws on the first model + final AtomicInteger executorCalls = new AtomicInteger(); + final String result = LangChain4jAIClient.get().executeWithFallback( + "test", "chat", config, cache, + cfg -> cfg.model(), // model instance = model name string + model -> { + if (executorCalls.getAndIncrement() == 0) { + throw new RuntimeException("simulated runtime failure"); + } + return "response-from-" + model; + }); + + assertEquals("response-from-good-model", result); + } + + @Test + public void test_executeWithFallback_allModelsFail_rethrowsLastException() { + final ProviderConfig config = configWithModels("model-a,model-b"); + final Cache cache = freshCache(); + final RuntimeException secondException = new RuntimeException("model-b failed"); + + final AtomicInteger callCount = new AtomicInteger(); + final RuntimeException thrown = assertThrows( + RuntimeException.class, + () -> LangChain4jAIClient.get().executeWithFallback( + "test", "chat", config, cache, + cfg -> cfg.model(), + model -> { + if (callCount.getAndIncrement() == 0) { + throw new RuntimeException("model-a failed"); + } + throw secondException; + })); + + assertEquals(secondException, thrown); + } + + @Test + public void test_executeWithFallback_noModelsConfigured_throwsImmediately() { + final ProviderConfig config = configWithModels(null); + final Cache cache = freshCache(); + + assertThrows( + IllegalArgumentException.class, + () -> LangChain4jAIClient.get().executeWithFallback( + "test", "chat", config, cache, + cfg -> "ignored", + model -> "ignored")); + } + + @Test + public void test_executeWithFallback_singleModel_success() { + final ProviderConfig config = configWithModels("gpt-4o"); + final Cache cache = freshCache(); + + final String result = LangChain4jAIClient.get().executeWithFallback( + "test", "chat", config, cache, + cfg -> "instance", + model -> "ok"); + + assertEquals("ok", result); + } + @Test public void test_sendRequest_disabledConfig_throws() { final AppConfig disabledConfig = mock(AppConfig.class); From cbcd812b45e2b0dc8e120a0370a8cfbb875abdba Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 21:23:18 -0300 Subject: [PATCH 19/28] test(ai): ProviderConfigMerger unit tests + Postman PUT config tests --- .../ai/app/ProviderConfigMergerTest.java | 155 +++++++++++++++ .../postman/AI.postman_collection.json | 188 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java diff --git a/dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java b/dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java new file mode 100644 index 000000000000..9692f9a7340f --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java @@ -0,0 +1,155 @@ +package com.dotcms.ai.app; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ProviderConfigMergerTest { + + // ------------------------------------------------------------------------- + // containsMasked + // ------------------------------------------------------------------------- + + @Test + public void test_containsMasked_withMaskedValue_returnsTrue() { + assertTrue(ProviderConfigMerger.containsMasked("{\"apiKey\":\"*****\"}")); + } + + @Test + public void test_containsMasked_withoutMaskedValue_returnsFalse() { + assertFalse(ProviderConfigMerger.containsMasked("{\"apiKey\":\"sk-real-key\"}")); + } + + @Test + public void test_containsMasked_blankInput_returnsFalse() { + assertFalse(ProviderConfigMerger.containsMasked("")); + assertFalse(ProviderConfigMerger.containsMasked(null)); + } + + @Test + public void test_containsMasked_partialMatch_doesNotMatch() { + // "****" (4 stars) is not the sentinel — must be exactly "*****" (5) + assertFalse(ProviderConfigMerger.containsMasked("{\"apiKey\":\"****\"}")); + } + + // ------------------------------------------------------------------------- + // merge — stored is blank + // ------------------------------------------------------------------------- + + @Test + public void test_merge_blankStored_returnsNewJsonUnchanged() { + final String newJson = "{\"chat\":{\"apiKey\":\"sk-new\"}}"; + assertEquals(newJson, ProviderConfigMerger.merge(newJson, "")); + assertEquals(newJson, ProviderConfigMerger.merge(newJson, null)); + } + + // ------------------------------------------------------------------------- + // merge — no masked values + // ------------------------------------------------------------------------- + + @Test + public void test_merge_noMaskedValues_returnsNewJsonStructure() throws Exception { + final String newJson = "{\"chat\":{\"apiKey\":\"sk-new\",\"model\":\"gpt-4o\"}}"; + final String storedJson = "{\"chat\":{\"apiKey\":\"sk-stored\",\"model\":\"gpt-3.5\"}}"; + + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + // new values win when no sentinel is present + assertTrue(result.contains("sk-new")); + assertTrue(result.contains("gpt-4o")); + assertFalse(result.contains("sk-stored")); + } + + // ------------------------------------------------------------------------- + // merge — masked top-level field + // ------------------------------------------------------------------------- + + @Test + public void test_merge_maskedTopLevelField_restoredFromStored() { + final String newJson = "{\"someKey\":\"*****\"}"; + final String storedJson = "{\"someKey\":\"real-value\"}"; + + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + assertTrue(result.contains("real-value")); + assertFalse(result.contains("*****")); + } + + // ------------------------------------------------------------------------- + // merge — masked nested credential field + // ------------------------------------------------------------------------- + + @Test + public void test_merge_maskedNestedApiKey_restoredFromStored() { + final String newJson = "{\"chat\":{\"apiKey\":\"*****\",\"model\":\"gpt-4o\"}}"; + final String storedJson = "{\"chat\":{\"apiKey\":\"sk-real-key\",\"model\":\"gpt-3.5\"}}"; + + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + assertTrue(result.contains("sk-real-key")); + assertTrue(result.contains("gpt-4o")); // non-credential field from new wins + assertFalse(result.contains("*****")); + } + + @Test + public void test_merge_allThreeSections_maskedCredsRestored() { + final String newJson = "{" + + "\"chat\":{\"apiKey\":\"*****\",\"model\":\"gpt-4o\"}," + + "\"embeddings\":{\"apiKey\":\"*****\",\"model\":\"ada-002\"}," + + "\"image\":{\"apiKey\":\"*****\",\"model\":\"dall-e-3\"}" + + "}"; + final String storedJson = "{" + + "\"chat\":{\"apiKey\":\"sk-chat\",\"model\":\"old-model\"}," + + "\"embeddings\":{\"apiKey\":\"sk-embed\",\"model\":\"old-embed\"}," + + "\"image\":{\"apiKey\":\"sk-image\",\"model\":\"old-image\"}" + + "}"; + + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + assertTrue(result.contains("sk-chat")); + assertTrue(result.contains("sk-embed")); + assertTrue(result.contains("sk-image")); + assertTrue(result.contains("gpt-4o")); + assertTrue(result.contains("ada-002")); + assertTrue(result.contains("dall-e-3")); + assertFalse(result.contains("*****")); + } + + // ------------------------------------------------------------------------- + // merge — masked field not present in stored + // ------------------------------------------------------------------------- + + @Test + public void test_merge_maskedFieldAbsentInStored_leftAsIs() { + final String newJson = "{\"chat\":{\"secretAccessKey\":\"*****\"}}"; + final String storedJson = "{\"chat\":{\"model\":\"gpt-4o\"}}"; + + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + // no stored value to restore — sentinel stays (better than silently losing it) + assertTrue(result.contains("*****")); + } + + // ------------------------------------------------------------------------- + // merge — invalid JSON + // ------------------------------------------------------------------------- + + @Test + public void test_merge_invalidNewJson_returnsNewJsonUnchanged() { + final String badJson = "not-valid-json"; + final String storedJson = "{\"chat\":{\"apiKey\":\"sk-real\"}}"; + + assertEquals(badJson, ProviderConfigMerger.merge(badJson, storedJson)); + } + + @Test + public void test_merge_invalidStoredJson_returnsNewJsonUnchanged() { + final String newJson = "{\"chat\":{\"apiKey\":\"*****\"}}"; + final String badStored = "not-valid-json"; + + assertEquals(newJson, ProviderConfigMerger.merge(newJson, badStored)); + } + +} diff --git a/dotcms-postman/src/main/resources/postman/AI.postman_collection.json b/dotcms-postman/src/main/resources/postman/AI.postman_collection.json index 9f008e4447b9..585c761373df 100644 --- a/dotcms-postman/src/main/resources/postman/AI.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/AI.postman_collection.json @@ -3375,6 +3375,194 @@ } }, "response": [] + }, + { + "name": "Config - Save config", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "const jsonData = pm.response.json();", + "", + "pm.test(\"Response contains providerConfig\", function () {", + " pm.expect(jsonData).to.have.property(\"providerConfig\");", + "});", + "", + "pm.test(\"Credentials are masked in response\", function () {", + " pm.expect(jsonData.providerConfig).to.include(\"*****\");", + "});", + "", + "pm.test(\"Model value is correct\", function () {", + " const pc = JSON.parse(jsonData.providerConfig);", + " pm.expect(pc.chat.model).to.equal(\"gpt-4o-mini\");", + "});", + "", + "// Save masked config for use in the next test", + "pm.environment.set(\"savedProviderConfig\", jsonData.providerConfig);" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"chat\": {\"provider\": \"openai\", \"apiKey\": \"some-api-key-1a2bc3\", \"model\": \"gpt-4o-mini\", \"maxTokens\": 8192, \"maxRetries\": 0, \"endpoint\": \"http://wm:8080/\"}, \"embeddings\": {\"provider\": \"openai\", \"apiKey\": \"some-api-key-1a2bc3\", \"model\": \"text-embedding-ada-002\", \"maxRetries\": 0, \"endpoint\": \"http://wm:8080/\"}, \"image\": {\"provider\": \"openai\", \"apiKey\": \"some-api-key-1a2bc3\", \"model\": \"dall-e-3\", \"size\": \"1024x1024\", \"maxRetries\": 0, \"endpoint\": \"http://wm:8080/\"}}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/ai/completions/config", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "ai", + "completions", + "config" + ] + } + }, + "response": [] + }, + { + "name": "Config - Masked creds preserved on re-save", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "const jsonData = pm.response.json();", + "", + "pm.test(\"Response contains providerConfig\", function () {", + " pm.expect(jsonData).to.have.property(\"providerConfig\");", + "});", + "", + "pm.test(\"Model value still intact after masked re-save\", function () {", + " const pc = JSON.parse(jsonData.providerConfig);", + " pm.expect(pc.chat.model).to.equal(\"gpt-4o-mini\");", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "admin", + "type": "string" + }, + { + "key": "username", + "value": "admin@dotcms.com", + "type": "string" + } + ] + }, + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{{savedProviderConfig}}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/ai/completions/config", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "ai", + "completions", + "config" + ] + } + }, + "response": [] + }, + { + "name": "Config - Unauthorized", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 401\", function () {", + " pm.response.to.have.status(401);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"chat\": {\"provider\": \"openai\", \"apiKey\": \"some-api-key-1a2bc3\", \"model\": \"gpt-4o-mini\", \"maxTokens\": 8192, \"maxRetries\": 0, \"endpoint\": \"http://wm:8080/\"}, \"embeddings\": {\"provider\": \"openai\", \"apiKey\": \"some-api-key-1a2bc3\", \"model\": \"text-embedding-ada-002\", \"maxRetries\": 0, \"endpoint\": \"http://wm:8080/\"}, \"image\": {\"provider\": \"openai\", \"apiKey\": \"some-api-key-1a2bc3\", \"model\": \"dall-e-3\", \"size\": \"1024x1024\", \"maxRetries\": 0, \"endpoint\": \"http://wm:8080/\"}}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/ai/completions/config", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "ai", + "completions", + "config" + ] + } + }, + "response": [] } ] } From 0ae65cdd8286bba83dc8d468f8dfbed5a9d6a41d Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 21:35:03 -0300 Subject: [PATCH 20/28] fix(postman): use invalid credentials in Config-Unauthorized test --- .../postman/AI.postman_collection.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/dotcms-postman/src/main/resources/postman/AI.postman_collection.json b/dotcms-postman/src/main/resources/postman/AI.postman_collection.json index 585c761373df..e6e1f4132c20 100644 --- a/dotcms-postman/src/main/resources/postman/AI.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/AI.postman_collection.json @@ -3560,6 +3560,21 @@ "completions", "config" ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "password", + "value": "wrong-password", + "type": "string" + }, + { + "key": "username", + "value": "invalid@example.com", + "type": "string" + } + ] } }, "response": [] @@ -3589,4 +3604,4 @@ } } ] -} \ No newline at end of file +} From 8fb6fd306d0ab2bc2b39d1d4342b7334778ecb2c Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 23 Apr 2026 21:46:30 -0300 Subject: [PATCH 21/28] fix(ai): address PR review issues #1-#6 #8 --- .../dot-ai-config-detail-resolver.service.ts | 19 ++++++------------ .../dot-ai-config-detail.component.html | 2 +- .../dot-ai-config-detail.component.ts | 13 ++++++++++++ .../app/portlets/dot-apps/dot-apps.routes.ts | 6 +++--- .../src/lib/dot-ai/dot-ai.service.ts | 5 ++--- .../dotcms/ai/app/ProviderConfigMerger.java | 11 +++++++--- .../dotcms/ai/rest/CompletionsResource.java | 14 +++++++++---- .../ai/app/ProviderConfigMergerTest.java | 20 +++++++++++++++---- 8 files changed, 59 insertions(+), 31 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts index eb15503783e3..60feeb236d7a 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail-resolver.service.ts @@ -1,7 +1,5 @@ -import { Observable } from 'rxjs'; - -import { Injectable, inject } from '@angular/core'; -import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; import { take } from 'rxjs/operators'; @@ -10,13 +8,8 @@ import { DotApp } from '@dotcms/dotcms-models'; const DOT_AI_APP_KEY = 'dotAI'; -@Injectable() -export class DotAiConfigDetailResolver implements Resolve { - private dotAppsService = inject(DotAppsService); - - resolve(route: ActivatedRouteSnapshot): Observable { - const id = route.paramMap.get('id'); +export const dotAiConfigDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const id = route.paramMap.get('id'); - return this.dotAppsService.getConfiguration(DOT_AI_APP_KEY, id).pipe(take(1)); - } -} + return inject(DotAppsService).getConfiguration(DOT_AI_APP_KEY, id).pipe(take(1)); +}; diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html index 42a83cfeae6f..161e0c829d73 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.html @@ -3,7 +3,7 @@
- {{ app.sites[0].name }} + {{ app.sites?.[0]?.name }}
- +
+ {{ app.sites?.[0]?.name }} +
+ +
-
-
-
-
-

Provider Config

+
+
+
+
+

Provider Config

-
-

Example JSON

-
{{ exampleJson }}
+
+

Example JSON

+
{{ exampleJson }}
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss index 9b6c7b1b4334..e69de29bb2d1 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss @@ -1,99 +0,0 @@ -@use "../../../../../../../../libs/dotcms-scss/shared/colors"; -@use "../../../../../../../../libs/dotcms-scss/shared/fonts"; -@use "../../../../../../../../libs/dotcms-scss/shared/shadows"; -@use "../../../../../../../../libs/dotcms-scss/shared/spacing"; - -:host { - background: colors.$color-palette-gray-200; - box-shadow: shadows.$shadow-m; - display: flex; - height: 100%; - padding: spacing.$spacing-4; -} - -.dot-ai-config-detail__header { - background-color: colors.$white; - position: sticky; - top: 0; - z-index: 1; -} - -.dot-ai-config-detail__actions button:last-child { - margin-left: spacing.$spacing-1; -} - -.dot-ai-config-detail__body { - flex-grow: 1; - overflow-y: auto; -} - -.dot-ai-config-detail__host-name { - border-bottom: 1px solid colors.$color-palette-gray-300; - color: colors.$black; - display: flex; - justify-content: space-between; - font-size: fonts.$font-size-lmd; - font-weight: fonts.$font-weight-semi-bold; - padding: spacing.$spacing-3; - - span { - align-items: center; - display: inline-flex; - } -} - -.dot-ai-config-detail__form-content { - margin: spacing.$spacing-4; -} - -.dot-ai-config-detail__container { - background-color: colors.$white; - box-shadow: shadows.$shadow-m; - display: flex; - flex-direction: column; - overflow-y: auto; - width: 100%; - font-size: fonts.$font-size-md; -} - -.dot-ai-config-detail__columns { - display: grid; - grid-template-columns: 1fr 1fr; - gap: spacing.$spacing-4; - align-items: start; -} - -.dot-ai-config-detail__column { - display: flex; - flex-direction: column; - gap: spacing.$spacing-2; -} - -.dot-ai-config-detail__column-title { - color: colors.$black; - font-size: fonts.$font-size-md; - font-weight: fonts.$font-weight-semi-bold; - margin: 0; -} - -.dot-ai-config-detail__textarea { - font-family: monospace; - font-size: fonts.$font-size-sm; - resize: vertical; - width: 100%; -} - -.dot-ai-config-detail__example { - background-color: colors.$color-palette-gray-100; - border: 1px solid colors.$color-palette-gray-300; - border-radius: 4px; - color: colors.$color-palette-gray-700; - font-family: monospace; - font-size: fonts.$font-size-sm; - line-height: 1.5; - margin: 0; - overflow-x: auto; - overflow-y: auto; - padding: spacing.$spacing-3; - white-space: pre; -} diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts index dbe4b4dc078d..ac6adfc447f0 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts @@ -1,11 +1,12 @@ -import { ChangeDetectorRef, Component, OnInit, inject } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { ButtonModule } from 'primeng/button'; import { TextareaModule } from 'primeng/textarea'; -import { map, take } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { DotAiService, @@ -48,6 +49,7 @@ const EXAMPLE_CONFIG = { selector: 'dot-ai-config-detail', templateUrl: './dot-ai-config-detail.component.html', styleUrls: ['./dot-ai-config-detail.component.scss'], + host: { class: 'flex h-full p-4 bg-gray-200 shadow-md' }, imports: [ FormsModule, ButtonModule, @@ -62,39 +64,36 @@ export class DotAiConfigDetailComponent implements OnInit { private dotRouterService = inject(DotRouterService); private dotMessageDisplayService = inject(DotMessageDisplayService); private dotMessageService = inject(DotMessageService); - private cdr = inject(ChangeDetectorRef); + private destroyRef = inject(DestroyRef); - app: DotApp; - configJson = ''; - saving = false; + readonly app = signal(null); + readonly configJson = signal(''); + readonly saving = signal(false); readonly exampleJson = JSON.stringify(EXAMPLE_CONFIG, null, 2); ngOnInit(): void { this.route.data .pipe( map((x) => x?.data), - take(1) + takeUntilDestroyed(this.destroyRef) ) .subscribe((app: DotApp) => { - this.app = app; + this.app.set(app); }); this.dotAiService .getConfig() - .pipe(take(1)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: (config) => { if (config?.providerConfig) { try { - this.configJson = JSON.stringify( - JSON.parse(config.providerConfig), - null, - 2 + this.configJson.set( + JSON.stringify(JSON.parse(config.providerConfig), null, 2) ); } catch { - this.configJson = config.providerConfig; + this.configJson.set(config.providerConfig); } - this.cdr.detectChanges(); } }, error: (err) => { @@ -112,7 +111,7 @@ export class DotAiConfigDetailComponent implements OnInit { onSubmit(): void { try { - JSON.parse(this.configJson); + JSON.parse(this.configJson()); } catch { this.dotMessageDisplayService.push({ life: 5000, @@ -124,13 +123,13 @@ export class DotAiConfigDetailComponent implements OnInit { return; } - this.saving = true; + this.saving.set(true); this.dotAiService - .saveConfig(this.configJson) - .pipe(take(1)) + .saveConfig(this.configJson()) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: () => { - this.saving = false; + this.saving.set(false); this.dotMessageDisplayService.push({ life: 3000, message: this.dotMessageService.get('dot.common.message.saved'), @@ -139,7 +138,7 @@ export class DotAiConfigDetailComponent implements OnInit { }); }, error: (err) => { - this.saving = false; + this.saving.set(false); const detail = err?.error?.error ?? err?.message ?? 'Failed to save AI configuration'; this.dotMessageDisplayService.push({ @@ -153,6 +152,9 @@ export class DotAiConfigDetailComponent implements OnInit { } goToApps(): void { - this.dotRouterService.goToAppsConfiguration(this.app.key); + const app = this.app(); + if (app) { + this.dotRouterService.goToAppsConfiguration(app.key); + } } } From c3cc0cf35d20f23e9798a31da73c723eb5710424 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Fri, 24 Apr 2026 12:42:43 -0300 Subject: [PATCH 26/28] fix(ai): sentinel guard, move DotAiProviderConfig to models, fix goToApps no-op, drop empty SCSS --- .../dot-ai-config-detail.component.ts | 7 ++----- .../libs/data-access/src/lib/dot-ai/dot-ai.service.ts | 10 ++++------ core-web/libs/dotcms-models/src/lib/dot-ai.model.ts | 5 +++++ .../java/com/dotcms/ai/rest/CompletionsResource.java | 7 +++++++ 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts index ac6adfc447f0..9bb9a4e43a49 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.ts @@ -48,7 +48,6 @@ const EXAMPLE_CONFIG = { @Component({ selector: 'dot-ai-config-detail', templateUrl: './dot-ai-config-detail.component.html', - styleUrls: ['./dot-ai-config-detail.component.scss'], host: { class: 'flex h-full p-4 bg-gray-200 shadow-md' }, imports: [ FormsModule, @@ -152,9 +151,7 @@ export class DotAiConfigDetailComponent implements OnInit { } goToApps(): void { - const app = this.app(); - if (app) { - this.dotRouterService.goToAppsConfiguration(app.key); - } + const key = this.app()?.key ?? 'dotAI'; + this.dotRouterService.goToAppsConfiguration(key); } } diff --git a/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts b/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts index 62c771268f74..f56567c51a7d 100644 --- a/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts +++ b/core-web/libs/data-access/src/lib/dot-ai/dot-ai.service.ts @@ -10,20 +10,18 @@ import { AiPluginResponse, DotAIImageContent, DotAIImageOrientation, - DotAIImageResponse + DotAIImageResponse, + DotAiProviderConfig } from '@dotcms/dotcms-models'; +export { DotAiProviderConfig }; + export const AI_PLUGIN_KEY = { NOT_SET: 'NOT SET' }; export const API_ENDPOINT = '/api/v1/ai'; export const API_ENDPOINT_FOR_PUBLISH = '/api/v1/workflow/actions/default/fire/PUBLISH'; -export interface DotAiProviderConfig { - providerConfig: string; - configHost: string; -} - const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); diff --git a/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts b/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts index d7c2844da725..a30091c52767 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-ai.model.ts @@ -93,6 +93,11 @@ export interface DotAICompletionsConfig { textPrompt: string; } +export interface DotAiProviderConfig { + providerConfig: string; + configHost: string; +} + export interface DotAiError { code: string; message: string; diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index 0f885c54563a..98ad6e98b925 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -237,6 +237,13 @@ public Response saveConfig(@Context final HttpServletRequest request, ? ProviderConfigMerger.merge(body, current.getProviderConfig()) : body; + if (ProviderConfigMerger.containsMasked(merged)) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, + "Credential fields still contain placeholder values — provide real credentials or load the existing configuration first")) + .build(); + } + try { final JsonNode mergedRoot = REDACTION_MAPPER.readTree(merged); if (!mergedRoot.isObject()) { From c6f82c1bd1abfe8c16c1e3d1ce3a23298ca872cb Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Fri, 24 Apr 2026 13:00:06 -0300 Subject: [PATCH 27/28] fix(ai): replace containsMasked with containsMaskedCredential in post-merge guard --- .../dotcms/ai/app/ProviderConfigMerger.java | 41 ++++++++++++++++++- .../dotcms/ai/rest/CompletionsResource.java | 2 +- .../ai/app/ProviderConfigMergerTest.java | 31 ++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java b/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java index b413719e0ef5..9d8fcc77e3a5 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java +++ b/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java @@ -42,12 +42,51 @@ private ProviderConfigMerger() {} /** * Returns {@code true} if {@code json} contains at least one field value equal to - * {@value #MASKED} (fast string check, no parsing). + * {@value #MASKED} (fast string check, no parsing). Used as a cheap pre-filter before + * attempting a full merge — not suitable for post-merge validation because it also matches + * non-credential fields. Use {@link #containsMaskedCredential(String)} for that. */ public static boolean containsMasked(final String json) { return StringUtils.isNotBlank(json) && json.contains("\"" + MASKED + "\""); } + /** + * Returns {@code true} if {@code json} contains at least one credential field + * ({@code apiKey}, {@code secretAccessKey}, {@code accessKeyId}) whose value equals + * {@value #MASKED}. Unlike {@link #containsMasked(String)}, this method parses the JSON + * and restricts the check to {@link #CREDENTIAL_FIELDS}, so non-credential fields whose + * value happens to equal {@value #MASKED} do not trigger a false positive. + */ + public static boolean containsMaskedCredential(final String json) { + if (StringUtils.isBlank(json)) { + return false; + } + try { + return hasMaskedCredential(MAPPER.readTree(json)); + } catch (final Exception e) { + return false; + } + } + + private static boolean hasMaskedCredential(final JsonNode node) { + if (!node.isObject()) { + return false; + } + final Iterator> fields = node.fields(); + while (fields.hasNext()) { + final Map.Entry entry = fields.next(); + final JsonNode value = entry.getValue(); + if (CREDENTIAL_FIELDS.contains(entry.getKey()) + && value.isTextual() && MASKED.equals(value.asText())) { + return true; + } + if (value.isObject() && hasMaskedCredential(value)) { + return true; + } + } + return false; + } + /** * Merges {@code newJson} with {@code storedJson}, replacing any {@value #MASKED} values * in {@code newJson} with the corresponding real values from {@code storedJson}. diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java index 98ad6e98b925..9523ba680492 100644 --- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java +++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java @@ -237,7 +237,7 @@ public Response saveConfig(@Context final HttpServletRequest request, ? ProviderConfigMerger.merge(body, current.getProviderConfig()) : body; - if (ProviderConfigMerger.containsMasked(merged)) { + if (ProviderConfigMerger.containsMaskedCredential(merged)) { return Response.status(Response.Status.BAD_REQUEST) .entity(Map.of(AiKeys.ERROR, "Credential fields still contain placeholder values — provide real credentials or load the existing configuration first")) diff --git a/dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java b/dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java index df4c3846ce4f..67cd1e889724 100644 --- a/dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java +++ b/dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java @@ -34,6 +34,37 @@ public void test_containsMasked_partialMatch_doesNotMatch() { assertFalse(ProviderConfigMerger.containsMasked("{\"apiKey\":\"****\"}")); } + // ------------------------------------------------------------------------- + // containsMaskedCredential + // ------------------------------------------------------------------------- + + @Test + public void test_containsMaskedCredential_maskedApiKey_returnsTrue() { + assertTrue(ProviderConfigMerger.containsMaskedCredential("{\"apiKey\":\"*****\"}")); + } + + @Test + public void test_containsMaskedCredential_maskedNestedApiKey_returnsTrue() { + assertTrue(ProviderConfigMerger.containsMaskedCredential("{\"chat\":{\"apiKey\":\"*****\"}}")); + } + + @Test + public void test_containsMaskedCredential_maskedNonCredentialField_returnsFalse() { + // rolePrompt with ***** is not a credential field — must not block the save + assertFalse(ProviderConfigMerger.containsMaskedCredential("{\"chat\":{\"rolePrompt\":\"*****\",\"apiKey\":\"sk-real\"}}")); + } + + @Test + public void test_containsMaskedCredential_realCredentials_returnsFalse() { + assertFalse(ProviderConfigMerger.containsMaskedCredential("{\"apiKey\":\"sk-real\",\"model\":\"gpt-4o\"}")); + } + + @Test + public void test_containsMaskedCredential_blankInput_returnsFalse() { + assertFalse(ProviderConfigMerger.containsMaskedCredential("")); + assertFalse(ProviderConfigMerger.containsMaskedCredential(null)); + } + // ------------------------------------------------------------------------- // merge — stored is blank // ------------------------------------------------------------------------- From 1d90b9bc0227c46be0694db2aadc14a563aab040 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Fri, 24 Apr 2026 13:04:59 -0300 Subject: [PATCH 28/28] chore(ai): delete empty SCSS file --- .../dot-ai-config-detail/dot-ai-config-detail.component.scss | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-ai-config-detail/dot-ai-config-detail.component.scss deleted file mode 100644 index e69de29bb2d1..000000000000