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..60feeb236d7a --- /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,15 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, ResolveFn } 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'; + +export const dotAiConfigDetailResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { + const id = route.paramMap.get('id'); + + 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 new file mode 100644 index 000000000000..02f037bff6cd --- /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,38 @@ +@if (app(); as app) { +
+
+ +
+ {{ app.sites?.[0]?.name }} +
+ + +
+
+
+
+
+
+
+

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.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..9bb9a4e43a49 --- /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,157 @@ +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 } 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'; + +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', + host: { class: 'flex h-full p-4 bg-gray-200 shadow-md' }, + 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); + private destroyRef = inject(DestroyRef); + + 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), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((app: DotApp) => { + this.app.set(app); + }); + + this.dotAiService + .getConfig() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (config) => { + if (config?.providerConfig) { + try { + this.configJson.set( + JSON.stringify(JSON.parse(config.providerConfig), null, 2) + ); + } catch { + this.configJson.set(config.providerConfig); + } + } + }, + error: (err) => { + const detail = + err?.error?.error ?? err?.message ?? 'Failed to load AI configuration'; + this.dotMessageDisplayService.push({ + life: 5000, + message: detail, + severity: DotMessageSeverity.ERROR, + type: DotMessageType.SIMPLE_MESSAGE + }); + } + }); + } + + onSubmit(): void { + try { + JSON.parse(this.configJson()); + } catch { + this.dotMessageDisplayService.push({ + life: 5000, + message: 'Invalid JSON — please check the provider configuration', + severity: DotMessageSeverity.ERROR, + type: DotMessageType.SIMPLE_MESSAGE + }); + + return; + } + + this.saving.set(true); + this.dotAiService + .saveConfig(this.configJson()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.saving.set(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.set(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 { + const key = this.app()?.key ?? 'dotAI'; + this.dotRouterService.goToAppsConfiguration(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..ffaa6a11831e 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,8 @@ 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'; import { DotAppsListComponent } from './dot-apps-list/dot-apps-list.component'; @@ -10,6 +12,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: dotAiConfigDetailResolver + }, + providers: [DotAppsService] + }, { 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..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 @@ -8,12 +8,14 @@ import { catchError, map, switchMap } from 'rxjs/operators'; import { DotCMSContentlet, AiPluginResponse, - DotAICompletionsConfig, DotAIImageContent, DotAIImageOrientation, - DotAIImageResponse + DotAIImageResponse, + DotAiProviderConfig } from '@dotcms/dotcms-models'; +export { DotAiProviderConfig }; + export const AI_PLUGIN_KEY = { NOT_SET: 'NOT SET' }; @@ -105,17 +107,27 @@ export class DotAiService { */ checkPluginInstallation(): Observable { return this.#http - .get(`${API_ENDPOINT}/completions/config`, { + .get(`${API_ENDPOINT}/completions/config`, { observe: 'response' }) .pipe( - map((res) => res.status === 200 && res?.body?.apiKey !== AI_PLUGIN_KEY.NOT_SET), + map((res) => res.status === 200 && !!res?.body?.providerConfig), catchError(() => { return of(false); }) ); } + 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/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/app/AppConfig.java b/dotCMS/src/main/java/com/dotcms/ai/app/AppConfig.java index a46f7ac058f9..369bbfad59b4 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)); @@ -327,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() { @@ -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..9d8fcc77e3a5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/app/ProviderConfigMerger.java @@ -0,0 +1,148 @@ +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; +import java.util.Set; + +/** + * 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 credential field ({@code apiKey}, {@code secretAccessKey}, {@code accessKeyId}) + * equal to {@value #MASKED} in the incoming JSON is replaced with the corresponding + * real value from the stored JSON, if present. Non-credential fields are left as-is + * even if their value happens to equal {@value #MASKED}.
  • + *
  • 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 = "*****"; + public static final Set CREDENTIAL_FIELDS = Set.of("apiKey", "secretAccessKey", "accessKeyId"); + 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). 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}. + * + * @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, + * not a JSON object, 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); + if (!storedRoot.isObject()) { + Logger.warn(ProviderConfigMerger.class, + "Stored providerConfig is not a JSON object; skipping merge to avoid persisting sentinel values"); + return newJson; + } + 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()) + && CREDENTIAL_FIELDS.contains(key)) { + 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/client/langchain4j/LangChain4jAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/LangChain4jAIClient.java index 32fae81c14a9..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; @@ -32,7 +33,6 @@ import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.image.ImageModel; import dev.langchain4j.model.output.FinishReason; -import dev.langchain4j.model.output.Response; import dev.langchain4j.model.output.TokenUsage; import io.vavr.Lazy; @@ -42,6 +42,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.function.Function; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -153,38 +154,24 @@ 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 messages = toMessages(payload.optJSONArray(AiKeys.MESSAGES)); if (messages.isEmpty()) { throw new IllegalArgumentException("Chat request must contain at least one message"); } - - final ChatResponse response = model.chat( - ChatRequest.builder().messages(messages).build()); - return toChatResponseJson(response); + return executeWithFallback(cacheKeyPrefix, "chat", baseConfig, chatModelCache, + LangChain4jModelFactory::buildChatModel, + model -> toChatResponseJson(model.chat(ChatRequest.builder().messages(messages).build()))); } private void executeStreamingChatRequest(final String cacheKeyPrefix, final String providerConfigJson, final JSONObject payload, final OutputStream output) { - final StreamingChatModel model; - try { - model = streamingChatModelCache.get( - cacheKeyPrefix + ":chat:streaming", - () -> LangChain4jModelFactory.buildStreamingChatModel(parseSection(providerConfigJson, "chat"))); - } catch (ExecutionException | UncheckedExecutionException e) { - final Throwable cause = e.getCause() != null ? e.getCause() : e; - throw new IllegalArgumentException("Failed to initialize streaming chat model: " + cause.getMessage(), cause); + final ProviderConfig baseConfig = parseSection(providerConfigJson, "chat"); + final List models = baseConfig.allModels(); + if (models.isEmpty()) { + throw new IllegalArgumentException("No model configured in providerConfig.chat — set 'model'"); } final List messages = toMessages(payload.optJSONArray(AiKeys.MESSAGES)); @@ -192,7 +179,42 @@ private void executeStreamingChatRequest(final String cacheKeyPrefix, throw new IllegalArgumentException("Chat request must contain at least one message"); } + final StreamingChatModel model = initStreamingModel(cacheKeyPrefix, baseConfig, models); + streamWithModel(model, messages, output); + } + + // Fallback is only possible before streaming starts — once bytes are written to output + // we cannot retry. Each init failure is logged immediately; the last exception is + // rethrown only after all configured fallback models have been attempted. + private StreamingChatModel initStreamingModel( + final String cacheKeyPrefix, + final ProviderConfig baseConfig, + final List models) { + RuntimeException lastException = null; + for (final String modelName : models) { + try { + final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); + return streamingChatModelCache.get( + cacheKeyPrefix + ":chat:streaming:" + modelName, + () -> LangChain4jModelFactory.buildStreamingChatModel(modelConfig)); + } catch (ExecutionException | UncheckedExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + lastException = new IllegalArgumentException( + "Failed to initialize streaming model '" + modelName + "': " + cause.getMessage(), cause); + Logger.warn(LangChain4jAIClient.class, + "Streaming model '" + modelName + "' init failed: " + cause.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } + } + throw lastException != null ? lastException + : new IllegalArgumentException("All configured streaming chat models exhausted"); + } + + private void streamWithModel(final StreamingChatModel model, + final List 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<>(); @@ -206,7 +228,9 @@ public void onPartialResponse(final String token) { } try { output.write(toSseChunk(token).getBytes(StandardCharsets.UTF_8)); + output.flush(); } catch (IOException e) { + cancelled.set(true); error.set(e); latch.countDown(); } @@ -238,6 +262,8 @@ public void onError(final Throwable e) { "Streaming timed out after " + STREAMING_TIMEOUT_SECONDS + " seconds", new java.util.concurrent.TimeoutException()); } + Logger.info(LangChain4jAIClient.class, + "Streaming chat completed in " + (System.currentTimeMillis() - start) + "ms"); } catch (InterruptedException e) { cancelled.set(true); Thread.currentThread().interrupt(); @@ -273,35 +299,65 @@ private void writeToOutput(final String responseJson, final OutputStream output) } private String executeEmbeddingRequest(final String cacheKeyPrefix, final String providerConfigJson, final JSONObject payload) { - final EmbeddingModel model; - try { - model = embeddingModelCache.get( - cacheKeyPrefix + ":embeddings", - () -> LangChain4jModelFactory.buildEmbeddingModel(parseSection(providerConfigJson, "embeddings"))); - } catch (ExecutionException | UncheckedExecutionException e) { - final Throwable cause = e.getCause() != null ? e.getCause() : e; - throw new IllegalArgumentException("Failed to initialize embedding model: " + cause.getMessage(), cause); - } - + final ProviderConfig baseConfig = parseSection(providerConfigJson, "embeddings"); final String input = payload.getString(AiKeys.INPUT); - final Response response = model.embed(TextSegment.from(input)); - return toEmbeddingResponseJson(response.content()); + return executeWithFallback(cacheKeyPrefix, "embeddings", baseConfig, embeddingModelCache, + LangChain4jModelFactory::buildEmbeddingModel, + model -> toEmbeddingResponseJson(model.embed(TextSegment.from(input)).content())); } private String executeImageRequest(final String cacheKeyPrefix, final String providerConfigJson, final JSONObject payload) { - final ImageModel model; - try { - model = imageModelCache.get( - cacheKeyPrefix + ":image", - () -> LangChain4jModelFactory.buildImageModel(parseSection(providerConfigJson, "image"))); - } catch (ExecutionException | UncheckedExecutionException e) { - final Throwable cause = e.getCause() != null ? e.getCause() : e; - throw new IllegalArgumentException("Failed to initialize image model: " + cause.getMessage(), cause); - } - + final ProviderConfig baseConfig = parseSection(providerConfigJson, "image"); final String prompt = payload.getString(AiKeys.PROMPT); - final Response response = model.generate(prompt); - return toImageResponseJson(response.content()); + return executeWithFallback(cacheKeyPrefix, "image", baseConfig, imageModelCache, + LangChain4jModelFactory::buildImageModel, + model -> toImageResponseJson(model.generate(prompt).content())); + } + + @VisibleForTesting + 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." + 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 { + final ProviderConfig modelConfig = ImmutableProviderConfig.copyOf(baseConfig).withModel(modelName); + final M model = modelCache.get( + cacheKeyPrefix + ":" + section + ":" + modelName, + () -> modelBuilder.apply(modelConfig)); + final long start = System.currentTimeMillis(); + final String result = executor.apply(model); + Logger.info(LangChain4jAIClient.class, + section + " model '" + modelName + "' responded in " + + (System.currentTimeMillis() - start) + "ms"); + return result; + } catch (ExecutionException | UncheckedExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + lastException = new IllegalArgumentException( + "Failed to initialize " + section + " model '" + modelName + "': " + cause.getMessage(), cause); + Logger.warn(LangChain4jAIClient.class, + section + " model '" + modelName + "' init failed: " + cause.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } catch (RuntimeException e) { + lastException = e; + Logger.warn(LangChain4jAIClient.class, + section + " model '" + modelName + "' failed: " + e.getMessage() + + (models.size() > 1 ? " — trying next model" : "")); + } + } + throw lastException != null ? lastException + : new IllegalArgumentException("All configured " + section + " models exhausted"); } static List toMessages(final JSONArray messagesArray) { @@ -395,6 +451,9 @@ static String toImageResponseJson(final Image image) { } private static ProviderConfig parseSection(final String providerConfigJson, final String section) { + if (providerConfigJson == null) { + throw new IllegalArgumentException("providerConfig is null — app config is not enabled"); + } try { final JsonNode root = MAPPER.readTree(providerConfigJson); final JsonNode sectionNode = root.get(section); 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..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; @@ -101,6 +102,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() != null ? config.model() : ""; + 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 +142,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(); } @@ -130,13 +150,12 @@ 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()); - 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(); } 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..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 @@ -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 List.copyOf(result); + } + @Nullable Integer maxTokens(); @Nullable Integer maxCompletionTokens(); @Nullable Double temperature(); 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..9523ba680492 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; @@ -18,7 +21,6 @@ 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; @@ -29,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; @@ -40,9 +45,7 @@ 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; import java.util.function.Supplier; @@ -55,7 +58,6 @@ public class CompletionsResource { private static final ObjectMapper REDACTION_MAPPER = DotObjectMapperProvider.createDefaultMapper(); - private static final Set CREDENTIAL_FIELDS = Set.of("apiKey", "secretAccessKey", "accessKeyId"); /** * Handles POST requests to generate completions based on a given prompt. @@ -183,16 +185,103 @@ 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(); } + @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; + + 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")) + .build(); + } + + try { + final JsonNode mergedRoot = REDACTION_MAPPER.readTree(merged); + if (!mergedRoot.isObject()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, "Request body must be a JSON object")) + .build(); + } + } catch (final Exception parseEx) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of(AiKeys.ERROR, "Invalid JSON in request body")) + .build(); + } + + 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")) + .build(); + } + } + private static String redactCredentials(final String json) { try { final JsonNode root = REDACTION_MAPPER.readTree(json); @@ -210,7 +299,7 @@ private static void redactNode(final JsonNode node) { final Iterator> fields = obj.fields(); while (fields.hasNext()) { final Map.Entry field = fields.next(); - if (CREDENTIAL_FIELDS.contains(field.getKey())) { + if (ProviderConfigMerger.CREDENTIAL_FIELDS.contains(field.getKey())) { obj.put(field.getKey(), "*****"); } else { redactNode(field.getValue()); diff --git a/dotCMS/src/main/resources/apps/dotAI.yml b/dotCMS/src/main/resources/apps/dotAI.yml index 3192c882d892..25fefb2e7cf9 100644 --- a/dotCMS/src/main/resources/apps/dotAI.yml +++ b/dotCMS/src/main/resources/apps/dotAI.yml @@ -2,10 +2,10 @@ 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: providerConfig: @@ -14,75 +14,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: - { - "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" } - } - 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. - ``` + 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): { - "default": "blog,news,webPageContent", - "blogsOnly": "blog.blogcontent" + "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." + } } - ``` - 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 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..67cd1e889724 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/ai/app/ProviderConfigMergerTest.java @@ -0,0 +1,224 @@ +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\":\"****\"}")); + } + + // ------------------------------------------------------------------------- + // 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 + // ------------------------------------------------------------------------- + + @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 credential field + // ------------------------------------------------------------------------- + + @Test + public void test_merge_maskedTopLevelCredentialField_restoredFromStored() { + final String newJson = "{\"apiKey\":\"*****\"}"; + final String storedJson = "{\"apiKey\":\"sk-real-key\"}"; + + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + assertTrue(result.contains("sk-real-key")); + assertFalse(result.contains("*****")); + } + + @Test + public void test_merge_maskedNonCredentialField_leftAsIs() { + // Only CREDENTIAL_FIELDS are restored — other fields with ***** stay as-is + final String newJson = "{\"someKey\":\"*****\"}"; + final String storedJson = "{\"someKey\":\"real-value\"}"; + + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + assertTrue(result.contains("*****")); + assertFalse(result.contains("real-value")); + } + + // ------------------------------------------------------------------------- + // 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 — storedJson is valid JSON but not an object + // ------------------------------------------------------------------------- + + @Test + public void test_merge_storedJsonIsArray_returnsNewJsonUnchanged() { + final String newJson = "{\"chat\":{\"apiKey\":\"*****\"}}"; + final String storedJson = "[\"not\",\"an\",\"object\"]"; + + // storedJson is valid JSON but not an object — merge is skipped to avoid + // persisting the ***** sentinel as a real credential value + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + assertEquals(newJson, result); + } + + @Test + public void test_merge_storedJsonIsNull_returnsNewJsonUnchanged() { + final String newJson = "{\"chat\":{\"apiKey\":\"*****\"}}"; + final String storedJson = "null"; + + final String result = ProviderConfigMerger.merge(newJson, storedJson); + + assertEquals(newJson, result); + } + + // ------------------------------------------------------------------------- + // 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/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); 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()); 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..b26ece90ccd3 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\");", "});", "" ], @@ -3382,6 +3375,151 @@ } }, "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": [] } ] } @@ -3408,4 +3546,4 @@ } } ] -} \ No newline at end of file +}