> fields = new EnumMap<>(Capability.class);
+ for (final Capability capability : strategy.supportedCapabilities()) {
+ fields.put(capability, strategy.configFields(capability));
+ }
+ return new ProviderMetadata(strategy.providerName(), strategy.supportedCapabilities(), fields);
+ }
+
private static ModelProviderStrategy resolve(final ProviderConfig config, final String modelType) {
if (config == null || config.provider() == null) {
throw new IllegalArgumentException(
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ModelProviderStrategy.java
index 90bba11d8b39..8387cbd4d272 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ModelProviderStrategy.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ModelProviderStrategy.java
@@ -5,16 +5,22 @@
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.image.ImageModel;
+import java.util.List;
+import java.util.Set;
+
/**
* Strategy interface for LangChain4J model construction.
*
* Each AI provider implements this interface and is registered in
* {@link LangChain4jModelFactory#STRATEGIES}. Adding a new provider requires only:
*
- * - Creating a new implementation of this interface
+ * - Creating a new implementation of this interface, including
+ * {@link #supportedCapabilities()} and {@link #configFields(Capability)}
* - Adding it to the {@code STRATEGIES} list in {@link LangChain4jModelFactory}
*
- * No other class needs to change.
+ * No other class needs to change — {@link LangChain4jModelFactory#listProviderMetadata()} picks up
+ * the new provider's capabilities and fields automatically, so a REST client can render its
+ * configuration form without any additional code.
*
* The {@code modelType} parameter in each build method is the section name
* ({@code "chat"}, {@code "embeddings"}, {@code "image"}) used solely for
@@ -37,6 +43,18 @@ interface ModelProviderStrategy {
ImageModel buildImageModel(ProviderConfig config, String modelType);
+ /**
+ * Returns the capabilities this provider can serve. A {@code build*Model} method for a
+ * capability not in this set throws {@link UnsupportedOperationException}.
+ */
+ Set supportedCapabilities();
+
+ /**
+ * Returns the {@link ProviderConfig} fields this provider reads for the given capability,
+ * with which are required. Only called for capabilities in {@link #supportedCapabilities()}.
+ */
+ List configFields(Capability capability);
+
/**
* Shared validation helper available to all strategy implementations.
*/
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java
index 36dafb44805a..86fcce6f421d 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenAiModelProviderStrategy.java
@@ -11,6 +11,8 @@
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import java.time.Duration;
+import java.util.List;
+import java.util.Set;
import java.util.function.Consumer;
class OpenAiModelProviderStrategy implements ModelProviderStrategy {
@@ -20,6 +22,39 @@ public String providerName() {
return "openai";
}
+ @Override
+ public Set supportedCapabilities() {
+ return Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE);
+ }
+
+ @Override
+ public List configFields(final Capability capability) {
+ return switch (capability) {
+ case CHAT -> List.of(
+ ProviderField.required("apiKey", ProviderFieldType.SECRET),
+ ProviderField.required("model", ProviderFieldType.STRING),
+ ProviderField.optional("endpoint", ProviderFieldType.STRING, "Override the default OpenAI base URL"),
+ ProviderField.optional("temperature", ProviderFieldType.NUMBER),
+ ProviderField.optional("maxTokens", ProviderFieldType.NUMBER),
+ ProviderField.optional("maxRetries", ProviderFieldType.NUMBER),
+ ProviderField.optional("timeout", ProviderFieldType.NUMBER, "Request timeout in seconds"));
+ case EMBEDDINGS -> List.of(
+ ProviderField.required("apiKey", ProviderFieldType.SECRET),
+ ProviderField.required("model", ProviderFieldType.STRING),
+ ProviderField.optional("endpoint", ProviderFieldType.STRING),
+ ProviderField.optional("dimensions", ProviderFieldType.NUMBER),
+ ProviderField.optional("maxRetries", ProviderFieldType.NUMBER),
+ ProviderField.optional("timeout", ProviderFieldType.NUMBER));
+ case IMAGE -> List.of(
+ ProviderField.required("apiKey", ProviderFieldType.SECRET),
+ ProviderField.required("model", ProviderFieldType.STRING),
+ ProviderField.optional("endpoint", ProviderFieldType.STRING),
+ ProviderField.optional("size", ProviderFieldType.STRING, "e.g. 1024x1024"),
+ ProviderField.optional("maxRetries", ProviderFieldType.NUMBER),
+ ProviderField.optional("timeout", ProviderFieldType.NUMBER));
+ };
+ }
+
@Override
public ChatModel buildChatModel(final ProviderConfig config, final String modelType) {
validate(config, modelType);
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java
index db0e4819943b..c948f0e51e39 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/OpenRouterModelProviderStrategy.java
@@ -10,6 +10,8 @@
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import java.time.Duration;
+import java.util.List;
+import java.util.Set;
/**
* {@link ModelProviderStrategy} implementation for OpenRouter.
@@ -34,6 +36,38 @@ public String providerName() {
return "openrouter";
}
+ @Override
+ public Set supportedCapabilities() {
+ return Set.of(Capability.CHAT, Capability.EMBEDDINGS);
+ }
+
+ @Override
+ public List configFields(final Capability capability) {
+ return switch (capability) {
+ case CHAT -> List.of(
+ ProviderField.required("apiKey", ProviderFieldType.SECRET),
+ ProviderField.required("model", ProviderFieldType.STRING, "Namespaced model ID, e.g. openai/gpt-4o"),
+ ProviderField.optional("endpoint", ProviderFieldType.STRING,
+ "Defaults to " + DEFAULT_BASE_URL),
+ ProviderField.optional("temperature", ProviderFieldType.NUMBER),
+ ProviderField.optional("maxTokens", ProviderFieldType.NUMBER),
+ ProviderField.optional("maxRetries", ProviderFieldType.NUMBER, "Not applied to streaming requests"),
+ ProviderField.optional("timeout", ProviderFieldType.NUMBER));
+ case EMBEDDINGS -> List.of(
+ ProviderField.required("apiKey", ProviderFieldType.SECRET),
+ ProviderField.required("model", ProviderFieldType.STRING,
+ "Namespaced model ID, e.g. openai/text-embedding-3-small"),
+ ProviderField.optional("endpoint", ProviderFieldType.STRING,
+ "Defaults to " + DEFAULT_BASE_URL),
+ ProviderField.optional("dimensions", ProviderFieldType.NUMBER),
+ ProviderField.optional("maxRetries", ProviderFieldType.NUMBER),
+ ProviderField.optional("timeout", ProviderFieldType.NUMBER));
+ case IMAGE -> throw new UnsupportedOperationException(
+ "OpenRouter image generation is not supported: the /api/v1/images endpoint "
+ + "is not OpenAI-shaped and cannot be driven by OpenAiImageModel.");
+ };
+ }
+
@Override
public ChatModel buildChatModel(final ProviderConfig config, final String modelType) {
validate(config, modelType);
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTester.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTester.java
new file mode 100644
index 000000000000..546d975fe74e
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTester.java
@@ -0,0 +1,139 @@
+package com.dotcms.ai.client.langchain4j;
+
+import com.dotmarketing.util.Logger;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.embedding.EmbeddingModel;
+import dev.langchain4j.model.image.ImageModel;
+
+import java.util.List;
+
+/**
+ * Verifies that a {@link ProviderConfig} actually works by building the LangChain4J model for the
+ * requested {@link Capability} and issuing one minimal, real request against the provider.
+ *
+ * Building the model already validates required fields (see {@link ModelProviderStrategy}),
+ * so a missing {@code apiKey}/{@code model} fails fast with a clear message before any network
+ * call is made. Anything past that — bad credentials, unreachable endpoint, unknown model — only
+ * surfaces once the provider actually answers, hence the real call.
+ */
+public final class ProviderConnectionTester {
+
+ private static final String TEST_PROMPT = "Reply with just the word: OK";
+ private static final String TEST_EMBEDDING_INPUT = "dotCMS connection test";
+ private static final String TEST_IMAGE_PROMPT = "a single red pixel on a white background";
+
+ /**
+ * Provider SDK exceptions (OpenAI, Bedrock, Google, etc.) often carry the full raw HTTP
+ * response body in {@link Exception#getMessage()} — sometimes several KB of JSON. Capping the
+ * length keeps the UI toast readable; the untruncated message is still recorded via
+ * {@link Logger#warn} in {@link #test} for anyone debugging the actual failure.
+ */
+ private static final int MAX_MESSAGE_LENGTH = 200;
+ private static final String TRUNCATION_SUFFIX = "…";
+
+ /**
+ * Upper bound applied to the test call when the posted config doesn't set {@code timeout}.
+ * Every provider strategy honors {@code timeout} once set (see each strategy's {@code build*}
+ * methods) except Vertex AI, which ignores it outright regardless of source — so this default
+ * only ever narrows an otherwise-unbounded provider-SDK default, never overrides an explicit
+ * value the caller supplied. Without this, an unreachable or slow {@code endpoint} could hold
+ * the request thread open indefinitely, since the SDKs' own defaults vary by provider and
+ * aren't all finite.
+ */
+ private static final int DEFAULT_TEST_TIMEOUT_SECONDS = 10;
+
+ /**
+ * Same purpose as {@link #DEFAULT_TEST_TIMEOUT_SECONDS}, but for {@link Capability#IMAGE}:
+ * real image generation routinely takes well past 10s, so the chat/embeddings default would
+ * fail a perfectly healthy provider before it ever finishes rendering.
+ */
+ private static final int DEFAULT_IMAGE_TEST_TIMEOUT_SECONDS = 60;
+
+ private ProviderConnectionTester() {
+ }
+
+ /**
+ * Tests the given provider configuration for the given capability.
+ *
+ * @param capability which capability section to test (chat, embeddings, image)
+ * @param config the provider configuration to test — same shape as one {@code providerConfig} section
+ * @return a result carrying whether the call succeeded and a human-readable message
+ */
+ public static TestConnectionResult test(final Capability capability, final ProviderConfig config) {
+ final ProviderConfig effectiveConfig = withDefaultTimeoutIfUnset(config, capability);
+ try {
+ final String detail = switch (capability) {
+ case CHAT -> testChat(effectiveConfig);
+ case EMBEDDINGS -> testEmbeddings(effectiveConfig);
+ case IMAGE -> testImage(effectiveConfig);
+ };
+ return new TestConnectionResult(true, detail);
+ } catch (final Exception e) {
+ Logger.warn(ProviderConnectionTester.class,
+ "dotAI provider connection test failed for provider="
+ + config.provider() + ", capability=" + capability + ": " + e.getMessage());
+ return new TestConnectionResult(false, friendlyMessage(e));
+ }
+ }
+
+ /**
+ * Returns {@code config} unchanged when it already sets a {@code timeout}, otherwise a copy
+ * with a default applied — {@link #DEFAULT_IMAGE_TEST_TIMEOUT_SECONDS} for
+ * {@link Capability#IMAGE}, {@link #DEFAULT_TEST_TIMEOUT_SECONDS} for every other capability
+ * — scoped to this connection-test path only, so normal save/use of the configuration is
+ * unaffected.
+ */
+ static ProviderConfig withDefaultTimeoutIfUnset(final ProviderConfig config, final Capability capability) {
+ if (config.timeout() != null) {
+ return config;
+ }
+
+ final int defaultSeconds = capability == Capability.IMAGE
+ ? DEFAULT_IMAGE_TEST_TIMEOUT_SECONDS
+ : DEFAULT_TEST_TIMEOUT_SECONDS;
+ return ImmutableProviderConfig.copyOf(config).withTimeout(defaultSeconds);
+ }
+
+ private static String testChat(final ProviderConfig config) {
+ final ChatModel model = LangChain4jModelFactory.buildChatModel(config);
+ model.chat(ChatRequest.builder()
+ .messages(List.of(UserMessage.from(TEST_PROMPT)))
+ .build());
+ return "Connection successful.";
+ }
+
+ private static String testEmbeddings(final ProviderConfig config) {
+ final EmbeddingModel model = LangChain4jModelFactory.buildEmbeddingModel(config);
+ model.embed(TextSegment.from(TEST_EMBEDDING_INPUT)).content();
+ return "Connection successful.";
+ }
+
+ private static String testImage(final ProviderConfig config) {
+ final ImageModel model = LangChain4jModelFactory.buildImageModel(config);
+ model.generate(TEST_IMAGE_PROMPT).content();
+ return "Connection successful. A test image was generated.";
+ }
+
+ /**
+ * Reduces a provider exception to something short enough to show in a UI toast: collapses
+ * whitespace/newlines into single spaces and caps the length, appending
+ * {@value #TRUNCATION_SUFFIX} when text was cut. Falls back to the exception's simple class
+ * name when there's no message at all. Provider-agnostic on purpose — it doesn't parse any
+ * SDK's specific error shape, so it needs no per-provider maintenance.
+ */
+ static String friendlyMessage(final Exception e) {
+ final String message = e.getMessage();
+ if (message == null || message.isBlank()) {
+ return e.getClass().getSimpleName();
+ }
+
+ final String collapsed = message.trim().replaceAll("\\s+", " ");
+ return collapsed.length() > MAX_MESSAGE_LENGTH
+ ? collapsed.substring(0, MAX_MESSAGE_LENGTH).stripTrailing() + TRUNCATION_SUFFIX
+ : collapsed;
+ }
+
+}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderField.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderField.java
new file mode 100644
index 000000000000..1e45556ec965
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderField.java
@@ -0,0 +1,68 @@
+package com.dotcms.ai.client.langchain4j;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Describes a single configurable {@link ProviderConfig} property for one provider/capability
+ * combination, so a client can render the right input without hardcoding per-provider knowledge.
+ *
+ * @param name the {@link ProviderConfig} property name, e.g. {@code apiKey}, {@code maxRetries}
+ * @param type the field's primitive type
+ * @param required whether this field must always be set for the given provider/capability
+ * @param hint short human-readable guidance (e.g. cross-field dependencies); empty if none
+ * @param requiredUnless when non-empty, the name of a sibling field whose presence satisfies this
+ * field's requirement (e.g. Azure's {@code model} is required unless
+ * {@code deploymentName} is set, and vice versa). Only meaningful when
+ * {@code required} is {@code false} — a client should treat this field as
+ * required unless the named sibling has a value. Empty when there's no such
+ * either-or relationship.
+ */
+public record ProviderField(
+ @JsonProperty("name") String name,
+ @JsonProperty("type") ProviderFieldType type,
+ @JsonProperty("required") boolean required,
+ @JsonProperty("hint") String hint,
+ @JsonProperty("requiredUnless") String requiredUnless) {
+
+ public ProviderField {
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("ProviderField name is required");
+ }
+ if (type == null) {
+ throw new IllegalArgumentException("ProviderField type is required");
+ }
+ hint = hint == null ? "" : hint;
+ requiredUnless = requiredUnless == null ? "" : requiredUnless;
+ }
+
+ public static ProviderField required(final String name, final ProviderFieldType type) {
+ return new ProviderField(name, type, true, "", "");
+ }
+
+ public static ProviderField required(final String name, final ProviderFieldType type, final String hint) {
+ return new ProviderField(name, type, true, hint, "");
+ }
+
+ public static ProviderField optional(final String name, final ProviderFieldType type) {
+ return new ProviderField(name, type, false, "", "");
+ }
+
+ public static ProviderField optional(final String name, final ProviderFieldType type, final String hint) {
+ return new ProviderField(name, type, false, hint, "");
+ }
+
+ /**
+ * An optional field that's effectively required unless the named sibling field is set (e.g.
+ * Azure's {@code model}/{@code deploymentName} pair — either one is enough).
+ *
+ * @param name the field name
+ * @param type the field's primitive type
+ * @param requiredUnless the sibling field name whose presence satisfies this requirement
+ * @param hint short human-readable guidance
+ */
+ public static ProviderField optionalUnless(final String name, final ProviderFieldType type,
+ final String requiredUnless, final String hint) {
+ return new ProviderField(name, type, false, hint, requiredUnless);
+ }
+
+}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderFieldType.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderFieldType.java
new file mode 100644
index 000000000000..ee7ca584fbd7
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderFieldType.java
@@ -0,0 +1,11 @@
+package com.dotcms.ai.client.langchain4j;
+
+/**
+ * The primitive type of a {@link ProviderField}, used by a form-rendering client to pick the
+ * right input control without hardcoding per-field knowledge.
+ */
+public enum ProviderFieldType {
+ STRING,
+ NUMBER,
+ SECRET
+}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderMetadata.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderMetadata.java
new file mode 100644
index 000000000000..d48476cc3c34
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/ProviderMetadata.java
@@ -0,0 +1,34 @@
+package com.dotcms.ai.client.langchain4j;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Aggregated, provider-agnostic description of one {@link ModelProviderStrategy}: which
+ * capabilities it supports and which {@link ProviderField}s each supported capability needs.
+ * Built by {@link LangChain4jModelFactory#listProviderMetadata()} so a client (e.g. the dotAI
+ * provider configuration REST endpoint) can render the full provider configuration form without
+ * hardcoding per-provider knowledge. Adding a provider to
+ * {@link LangChain4jModelFactory#STRATEGIES} makes it appear here automatically.
+ *
+ * @param provider the provider identifier, e.g. {@code openai}, {@code azure_openai}
+ * @param supportedCapabilities capabilities this provider can serve
+ * @param fields config fields per supported capability
+ */
+public record ProviderMetadata(
+ @JsonProperty("provider") String provider,
+ @JsonProperty("supportedCapabilities") Set supportedCapabilities,
+ @JsonProperty("fields") Map> fields) {
+
+ public ProviderMetadata {
+ if (provider == null || provider.isBlank()) {
+ throw new IllegalArgumentException("ProviderMetadata provider is required");
+ }
+ supportedCapabilities = supportedCapabilities == null ? Set.of() : Set.copyOf(supportedCapabilities);
+ fields = fields == null ? Map.of() : Map.copyOf(fields);
+ }
+
+}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/TestConnectionResult.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/TestConnectionResult.java
new file mode 100644
index 000000000000..24e9af41e3da
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/TestConnectionResult.java
@@ -0,0 +1,14 @@
+package com.dotcms.ai.client.langchain4j;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Outcome of a {@link ProviderConnectionTester} run for one provider/capability combination.
+ *
+ * @param success whether the provider accepted the request
+ * @param message human-readable detail — a confirmation on success, the provider/validation error on failure
+ */
+public record TestConnectionResult(
+ @JsonProperty("success") boolean success,
+ @JsonProperty("message") String message) {
+}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/VertexAiModelProviderStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/VertexAiModelProviderStrategy.java
index 927284b3a329..d952f1d9f1bb 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/VertexAiModelProviderStrategy.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/client/langchain4j/VertexAiModelProviderStrategy.java
@@ -15,6 +15,8 @@
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Set;
/**
* {@link ModelProviderStrategy} implementation for Google Vertex AI.
@@ -38,6 +40,31 @@ public String providerName() {
return "vertex_ai";
}
+ @Override
+ public Set supportedCapabilities() {
+ return Set.of(Capability.CHAT);
+ }
+
+ @Override
+ public List configFields(final Capability capability) {
+ return switch (capability) {
+ case CHAT -> List.of(
+ ProviderField.required("model", ProviderFieldType.STRING),
+ ProviderField.required("projectId", ProviderFieldType.STRING),
+ ProviderField.required("location", ProviderFieldType.STRING),
+ ProviderField.optional("credentialsJson", ProviderFieldType.SECRET,
+ "GCP service account JSON key; omit to use Application Default Credentials"),
+ ProviderField.optional("temperature", ProviderFieldType.NUMBER),
+ ProviderField.optional("maxTokens", ProviderFieldType.NUMBER),
+ ProviderField.optional("maxRetries", ProviderFieldType.NUMBER,
+ "Ignored when credentialsJson is set"));
+ case EMBEDDINGS -> throw new UnsupportedOperationException(
+ "Embeddings are not supported for Vertex AI provider via LangChain4J");
+ case IMAGE -> throw new UnsupportedOperationException(
+ "Image generation is not supported for Vertex AI provider via LangChain4J");
+ };
+ }
+
@Override
public ChatModel buildChatModel(final ProviderConfig config, final String modelType) {
validate(config, modelType);
diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java b/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java
new file mode 100644
index 000000000000..492827d8a318
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/rest/AiHostResolver.java
@@ -0,0 +1,83 @@
+package com.dotcms.ai.rest;
+
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.business.web.WebAPILocator;
+import com.dotmarketing.exception.DotSecurityException;
+import com.dotmarketing.util.Logger;
+import com.liferay.portal.model.User;
+import org.apache.commons.lang3.StringUtils;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Resolves the target {@link Host} for a dotAI REST request from an optional {@code siteId}
+ * query parameter, falling back to the host derived from the HTTP request when the parameter is
+ * absent or unresolvable. Shared by every dotAI endpoint that reads or tests a per-site
+ * {@code providerConfig} ({@link CompletionsResource}, {@link AiProviderResource}).
+ */
+final class AiHostResolver {
+
+ private AiHostResolver() {
+ }
+
+ /**
+ * Resolves a host from {@code siteId} and falls back to the HTTP host on failure.
+ * Throws {@link DotSecurityException} when the user lacks permission for the requested site.
+ * Falls back to the HTTP-derived host when {@code siteId} is blank or not found.
+ */
+ static Host resolveHost(final String siteId,
+ final HttpServletRequest request,
+ final User user) throws DotSecurityException {
+ if (StringUtils.isNotBlank(siteId)) {
+ try {
+ final Host found = findHost(siteId, user);
+ if (found != null) {
+ return found;
+ }
+ } catch (final DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ Logger.warn(AiHostResolver.class,
+ "Could not resolve siteId '" + sanitize(siteId) + "', falling back to current host: " + e.getMessage());
+ }
+ }
+ return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request);
+ }
+
+ /**
+ * Resolves a host from {@code siteId} strictly — no fallback.
+ * Falls back to the HTTP-derived host when siteId is blank.
+ * Returns {@code null} when the site is not found.
+ * Throws {@link DotSecurityException} when the user lacks permission.
+ * Use for write operations where silently targeting the wrong site is unacceptable.
+ */
+ static Host resolveHostStrict(final String siteId,
+ final HttpServletRequest request,
+ final User user) throws DotSecurityException {
+ if (StringUtils.isBlank(siteId)) {
+ return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request);
+ }
+ try {
+ return findHost(siteId, user);
+ } catch (final DotSecurityException e) {
+ throw e;
+ } catch (final Exception e) {
+ Logger.warn(AiHostResolver.class, "Could not resolve siteId '" + sanitize(siteId) + "': " + e.getMessage());
+ return null;
+ }
+ }
+
+ static String sanitize(final String value) {
+ return value == null ? "null" : value.replaceAll("[\r\n\t]", "_");
+ }
+
+ private static Host findHost(final String siteId, final User user) throws Exception {
+ if ("SYSTEM_HOST".equalsIgnoreCase(siteId)) {
+ return APILocator.systemHost();
+ }
+ final Host found = APILocator.getHostAPI().find(siteId, user, false);
+ return (found != null && StringUtils.isNotBlank(found.getIdentifier()) && !found.isArchived()) ? found : null;
+ }
+
+}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/AiProviderResource.java b/dotCMS/src/main/java/com/dotcms/ai/rest/AiProviderResource.java
new file mode 100644
index 000000000000..f561ebb95186
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/rest/AiProviderResource.java
@@ -0,0 +1,269 @@
+package com.dotcms.ai.rest;
+
+import com.dotcms.ai.AiKeys;
+import com.dotcms.ai.app.AppConfig;
+import com.dotcms.ai.app.ConfigService;
+import com.dotcms.ai.app.ProviderConfigMerger;
+import com.dotcms.ai.client.langchain4j.Capability;
+import com.dotcms.ai.client.langchain4j.LangChain4jModelFactory;
+import com.dotcms.ai.client.langchain4j.ProviderConfig;
+import com.dotcms.ai.client.langchain4j.ProviderConnectionTester;
+import com.dotcms.ai.client.langchain4j.TestConnectionResult;
+import com.dotcms.rest.WebResource;
+import com.dotcms.rest.annotation.NoCache;
+import com.dotcms.rest.api.v1.DotObjectMapperProvider;
+import com.dotmarketing.beans.Host;
+import com.dotmarketing.exception.DotSecurityException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.liferay.portal.model.User;
+import io.swagger.v3.oas.annotations.Operation;
+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.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+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.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Exposes dotAI provider configuration metadata: which providers are available, which
+ * capabilities (chat, embeddings, image) each supports, and which {@code providerConfig} fields
+ * each capability needs. Backed entirely by {@link LangChain4jModelFactory#listProviderMetadata()}
+ * — adding a new provider there makes it appear here automatically, with no REST-layer change.
+ */
+@Path("/v1/ai/providers")
+@Tag(name = "AI", description = "AI-powered content generation and analysis endpoints")
+public class AiProviderResource {
+
+ private static final ObjectMapper MAPPER = DotObjectMapperProvider.createDefaultMapper();
+
+ /**
+ * Lists capability and field metadata for every registered dotAI provider.
+ *
+ * @param request the HttpServletRequest object.
+ * @param response the HttpServletResponse object.
+ * @return a Response wrapping the list of provider metadata.
+ */
+ @Operation(
+ operationId = "listAiProviders",
+ summary = "List dotAI provider configuration metadata",
+ description = "Returns, for every registered dotAI provider, the capabilities it "
+ + "supports (chat/embeddings/image) and the providerConfig fields each "
+ + "supported capability requires or accepts."
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200",
+ description = "Provider metadata retrieved successfully",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = ResponseEntityAiProviderListView.class))),
+ @ApiResponse(responseCode = "401",
+ description = "Unauthorized - authentication required",
+ content = @Content(mediaType = "application/json"))
+ })
+ @GET
+ @JSONP
+ @NoCache
+ @Path("/")
+ @Produces(MediaType.APPLICATION_JSON)
+ public final Response listProviders(@Context final HttpServletRequest request,
+ @Context final HttpServletResponse response) {
+
+ new WebResource.InitBuilder(request, response).requiredBackendUser(true).init();
+ return Response.ok(new ResponseEntityAiProviderListView(
+ LangChain4jModelFactory.listProviderMetadata())).build();
+ }
+
+ /**
+ * Tests whether a provider configuration actually works: builds the model for the requested
+ * capability and issues one minimal, real request against the provider (a short chat reply,
+ * a one-line embedding, or — for image — the generation of a single test image).
+ *
+ * The posted config section may carry masked credential fields (e.g. {@code "apiKey":
+ * "*****"}), left untouched by a client that only redisplays the previously-saved config. Any
+ * such masked field is resolved against the real value already stored for {@code siteId}
+ * before testing — mirroring how {@code PUT /v1/ai/completions/config} preserves unmasked
+ * credentials on save — so the real secret never has to round-trip through the browser.
+ * Resolution only happens when the posted {@code provider} and {@code endpoint} match what's
+ * actually stored, so a caller can't pair a masked credential with a different provider or an
+ * attacker-controlled {@code endpoint} to exfiltrate the real secret to it. Requires CMS admin,
+ * matching {@code PUT /v1/ai/completions/config}.
+ *
+ * @param request the HttpServletRequest object.
+ * @param response the HttpServletResponse object.
+ * @param capability which capability section to test — {@code chat}, {@code embeddings}, or {@code image}.
+ * @param siteId optional site identifier (or {@code SYSTEM_HOST}) whose stored config resolves masked
+ * credentials; falls back to the site derived from the HTTP Host header.
+ * @param body the provider config section to test, e.g. {@code {"provider":"openai","apiKey":"...","model":"gpt-4o"}}.
+ * @return a Response wrapping the test result: {@code success} plus a human-readable {@code message}.
+ */
+ @Operation(
+ operationId = "testAiProviderConnection",
+ summary = "Test a dotAI provider connection",
+ description = "Builds the provider client for the given capability from the posted "
+ + "configuration and issues one minimal real request against the provider "
+ + "(a short chat reply, a one-line embedding, or a single test image). "
+ + "Masked credential fields (\"*****\") in the posted config are resolved "
+ + "against the real value already stored for siteId before testing. "
+ + "Returns success=false with a message on any validation or provider error "
+ + "rather than an HTTP error status, so the caller can always render the result."
+ )
+ @ApiResponses(value = {
+ @ApiResponse(responseCode = "200",
+ description = "Test executed — check the success field for the outcome",
+ content = @Content(mediaType = "application/json",
+ schema = @Schema(implementation = ResponseEntityAiTestConnectionView.class))),
+ @ApiResponse(responseCode = "400",
+ description = "Unknown capability or malformed request body",
+ content = @Content(mediaType = "application/json")),
+ @ApiResponse(responseCode = "401",
+ description = "Unauthorized - authentication required",
+ content = @Content(mediaType = "application/json")),
+ @ApiResponse(responseCode = "403",
+ description = "Forbidden - requires CMS admin, or access denied to site",
+ content = @Content(mediaType = "application/json"))
+ })
+ @POST
+ @JSONP
+ @NoCache
+ @Path("/test/{capability}")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public final Response testConnection(@Context final HttpServletRequest request,
+ @Context final HttpServletResponse response,
+ @PathParam("capability") final String capability,
+ @QueryParam("siteId") final String siteId,
+ @RequestBody(description = "Provider config section to test",
+ content = @Content(schema = @Schema(implementation = Map.class)))
+ final String body) {
+
+ final User user = new WebResource.InitBuilder(request, response).requiredBackendUser(true).init().getUser();
+
+ if (!user.isAdmin()) {
+ return Response.status(Response.Status.FORBIDDEN)
+ .entity(Map.of(AiKeys.ERROR, "Only CMS admins can test the AI provider connection"))
+ .build();
+ }
+
+ final Capability parsedCapability;
+ try {
+ parsedCapability = Capability.valueOf(capability.toUpperCase(Locale.ROOT));
+ } catch (final IllegalArgumentException e) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity(Map.of(AiKeys.ERROR, "Unknown capability: " + AiHostResolver.sanitize(capability)))
+ .build();
+ }
+
+ if (StringUtils.isBlank(body)) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity(Map.of(AiKeys.ERROR, "Request body is required"))
+ .build();
+ }
+
+ final String resolvedBody;
+ try {
+ final Host host = AiHostResolver.resolveHostStrict(siteId, request, user);
+ if (host == null) {
+ final String msg = StringUtils.isNotBlank(siteId)
+ ? "Site not found: " + AiHostResolver.sanitize(siteId)
+ : "Could not resolve current site from request";
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity(Map.of(AiKeys.ERROR, msg))
+ .build();
+ }
+ final AppConfig storedConfig = ConfigService.INSTANCE.config(host);
+ resolvedBody = resolveMaskedCredentials(body, storedConfig.getProviderConfig(),
+ capability.toLowerCase(Locale.ROOT));
+ } catch (final DotSecurityException e) {
+ return Response.status(Response.Status.FORBIDDEN)
+ .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + AiHostResolver.sanitize(siteId)))
+ .build();
+ }
+
+ if (ProviderConfigMerger.containsMaskedCredential(resolvedBody)) {
+ return Response.ok(new ResponseEntityAiTestConnectionView(new TestConnectionResult(false,
+ "One or more credential fields still hold a placeholder value — "
+ + "re-enter them, or save the configuration first, then test again.")))
+ .build();
+ }
+
+ final ProviderConfig config;
+ try {
+ config = MAPPER.readValue(resolvedBody, ProviderConfig.class);
+ } catch (final Exception e) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity(Map.of(AiKeys.ERROR, "Invalid provider configuration: " + e.getMessage()))
+ .build();
+ }
+
+ final TestConnectionResult result = ProviderConnectionTester.test(parsedCapability, config);
+ return Response.ok(new ResponseEntityAiTestConnectionView(result)).build();
+ }
+
+ /**
+ * Resolves any {@code "*****"} masked credential field in {@code body} against the real value
+ * from the currently-stored {@code providerConfig}'s {@code sectionKey} section (e.g. {@code
+ * chat}, {@code embeddings}, {@code image}). Returns {@code body} unchanged — masked fields
+ * and all — when there's nothing masked, nothing stored yet, the stored section can't be
+ * parsed, or {@link #targetsStoredDestination} rejects the posted {@code provider}/{@code
+ * endpoint} as not matching what's stored; the caller then surfaces the still-masked
+ * credential as a "re-enter it" failure rather than silently using it.
+ */
+ static String resolveMaskedCredentials(final String body,
+ final String storedProviderConfigJson,
+ final String sectionKey) {
+ if (StringUtils.isBlank(storedProviderConfigJson) || !ProviderConfigMerger.containsMasked(body)) {
+ return body;
+ }
+ try {
+ final JsonNode incoming = MAPPER.readTree(body);
+ final JsonNode storedSection = MAPPER.readTree(storedProviderConfigJson).get(sectionKey);
+ if (storedSection == null || !storedSection.isObject()
+ || !targetsStoredDestination(incoming, storedSection)) {
+ return body;
+ }
+ return ProviderConfigMerger.merge(body, storedSection.toString());
+ } catch (final Exception e) {
+ return body;
+ }
+ }
+
+ /**
+ * Guards against pairing a masked credential (obtainable by anyone who can {@code GET} the
+ * redacted config) with a different {@code provider} or a caller-controlled {@code endpoint}
+ * — which would otherwise resolve to the real stored secret and send it to a destination the
+ * caller chose rather than the one it was actually saved for. Only {@code provider} and {@code
+ * endpoint} are checked: every other field (model, temperature, timeout, region, etc.) doesn't
+ * change where the request — and the secret riding along with it — is sent, so those can
+ * differ freely between the posted body and the stored config.
+ */
+ static boolean targetsStoredDestination(final JsonNode incoming, final JsonNode stored) {
+ return textEquals(incoming.get("provider"), stored.get("provider"))
+ && textEquals(incoming.get("endpoint"), stored.get("endpoint"));
+ }
+
+ static boolean textEquals(final JsonNode a, final JsonNode b) {
+ final String left = a != null && !a.isNull() ? a.asText() : null;
+ final String right = b != null && !b.isNull() ? b.asText() : null;
+
+ return Objects.equals(left, right);
+ }
+
+}
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 feb53422ec53..f36da6942d0e 100644
--- a/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java
+++ b/dotCMS/src/main/java/com/dotcms/ai/rest/CompletionsResource.java
@@ -184,10 +184,10 @@ public final Response getConfig(@Context final HttpServletRequest request,
.getUser();
final Host host;
try {
- host = resolveHost(siteId, request, user);
+ host = AiHostResolver.resolveHost(siteId, request, user);
} catch (final DotSecurityException e) {
return Response.status(Response.Status.FORBIDDEN)
- .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + sanitize(siteId)))
+ .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + AiHostResolver.sanitize(siteId)))
.build();
}
final AppConfig appConfig = ConfigService.INSTANCE.config(host);
@@ -255,10 +255,10 @@ public Response saveConfig(@Context final HttpServletRequest request,
}
try {
- final Host host = resolveHostStrict(siteId, request, user);
+ final Host host = AiHostResolver.resolveHostStrict(siteId, request, user);
if (host == null) {
final String msg = StringUtils.isNotBlank(siteId)
- ? "Site not found: " + sanitize(siteId)
+ ? "Site not found: " + AiHostResolver.sanitize(siteId)
: "Could not resolve current site from request";
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(AiKeys.ERROR, msg))
@@ -309,7 +309,7 @@ AppKeys.PROVIDER_CONFIG.key, redactCredentials(merged),
} catch (final DotSecurityException e) {
return Response.status(Response.Status.FORBIDDEN)
- .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + sanitize(siteId)))
+ .entity(Map.of(AiKeys.ERROR, "Access denied to site: " + AiHostResolver.sanitize(siteId)))
.build();
} catch (final Exception e) {
Logger.error(CompletionsResource.class, "Failed to save AI config: " + e.getMessage(), e);
@@ -347,65 +347,6 @@ private static void redactNode(final JsonNode node) {
}
}
- /**
- * Resolves a host from {@code siteId} and falls back to the HTTP host on failure.
- * Throws {@link DotSecurityException} when the user lacks permission for the requested site.
- * Falls back to the HTTP-derived host when {@code siteId} is blank or not found.
- */
- private static Host resolveHost(final String siteId,
- final HttpServletRequest request,
- final User user) throws DotSecurityException {
- if (StringUtils.isNotBlank(siteId)) {
- try {
- final Host found = findHost(siteId, user);
- if (found != null) {
- return found;
- }
- } catch (final DotSecurityException e) {
- throw e;
- } catch (final Exception e) {
- Logger.warn(CompletionsResource.class,
- "Could not resolve siteId '" + sanitize(siteId) + "', falling back to current host: " + e.getMessage());
- }
- }
- return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request);
- }
-
- /**
- * Resolves a host from {@code siteId} strictly — no fallback.
- * Falls back to the HTTP-derived host when siteId is blank.
- * Returns {@code null} when the site is not found.
- * Throws {@link DotSecurityException} when the user lacks permission.
- * Use for write operations where silently targeting the wrong site is unacceptable.
- */
- private static Host resolveHostStrict(final String siteId,
- final HttpServletRequest request,
- final User user) throws DotSecurityException {
- if (StringUtils.isBlank(siteId)) {
- return WebAPILocator.getHostWebAPI().getCurrentHostNoThrow(request);
- }
- try {
- return findHost(siteId, user);
- } catch (final DotSecurityException e) {
- throw e;
- } catch (final Exception e) {
- Logger.warn(CompletionsResource.class, "Could not resolve siteId '" + sanitize(siteId) + "': " + e.getMessage());
- return null;
- }
- }
-
- private static String sanitize(final String value) {
- return value == null ? "null" : value.replaceAll("[\r\n\t]", "_");
- }
-
- private static Host findHost(final String siteId, final User user) throws Exception {
- if ("SYSTEM_HOST".equalsIgnoreCase(siteId)) {
- return APILocator.systemHost();
- }
- final Host found = APILocator.getHostAPI().find(siteId, user, false);
- return (found != null && StringUtils.isNotBlank(found.getIdentifier()) && !found.isArchived()) ? found : null;
- }
-
private static Response badRequestResponse() {
return Response.status(Response.Status.BAD_REQUEST).entity(Map.of(AiKeys.ERROR, "query required")).build();
}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiProviderListView.java b/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiProviderListView.java
new file mode 100644
index 000000000000..15f9f86c367e
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiProviderListView.java
@@ -0,0 +1,15 @@
+package com.dotcms.ai.rest;
+
+import com.dotcms.ai.client.langchain4j.ProviderMetadata;
+import com.dotcms.rest.ResponseEntityView;
+
+import java.util.List;
+
+/**
+ * Entity View wrapping the dotAI provider configuration metadata list response.
+ */
+public class ResponseEntityAiProviderListView extends ResponseEntityView> {
+ public ResponseEntityAiProviderListView(final List entity) {
+ super(entity);
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiTestConnectionView.java b/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiTestConnectionView.java
new file mode 100644
index 000000000000..9a0daf8cbfb8
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/ai/rest/ResponseEntityAiTestConnectionView.java
@@ -0,0 +1,13 @@
+package com.dotcms.ai.rest;
+
+import com.dotcms.ai.client.langchain4j.TestConnectionResult;
+import com.dotcms.rest.ResponseEntityView;
+
+/**
+ * Entity View wrapping the dotAI provider connection test result response.
+ */
+public class ResponseEntityAiTestConnectionView extends ResponseEntityView {
+ public ResponseEntityAiTestConnectionView(final TestConnectionResult entity) {
+ super(entity);
+ }
+}
diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
index bbba45b0d4b5..378345760913 100644
--- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
+++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
@@ -5608,6 +5608,62 @@ apps.param.set.from.env=Set from the environment
apps.content-analytics.generated.string.placeholder=Generated string will appear here...
apps.content-analytics.generated.string.confirm.replace.header=Confirm Replacement
apps.content-analytics.generated.string.confirm.replace.message=The current value will be replaced: "{0}". Are you sure you want to generate a new string?
+apps.ai.config.title=dotAI Configuration
+apps.ai.config.subtitle=Configure the AI providers used for chat, embeddings and image generation. Each capability is configured independently, so you can mix providers.
+apps.ai.config.site=Site: {0}
+apps.ai.loading=Loading...
+apps.ai.unsaved.changes=Unsaved changes
+apps.ai.button.cancel=Cancel
+apps.ai.button.save=Save Configuration
+apps.ai.error.load=Failed to load AI configuration
+apps.ai.error.save=Failed to save AI configuration
+apps.ai.validation.required-fields=Please fill in the required fields before saving.
+apps.ai.capability.chat.title=Chat
+apps.ai.capability.chat.description=Text generation for AI Blocks, workflows and the $ai viewtool.
+apps.ai.capability.embeddings.title=Embeddings
+apps.ai.capability.embeddings.description=Vector indexing for semantic search over your content.
+apps.ai.capability.image.title=Image Generation
+apps.ai.capability.image.description=Generated imagery for content items and Block Editor.
+apps.ai.capability.chat.label=chat
+apps.ai.capability.embeddings.label=embeddings
+apps.ai.capability.image.label=images
+apps.ai.badge.not-configured=Not Configured
+apps.ai.provider.label=Provider
+apps.ai.provider.capability.unsupported=No {0} support
+apps.ai.advanced.optional.fields.header=Advanced {0} optional field(s)
+apps.ai.button.test-connection=Test Connection
+apps.ai.validation.required-fields-test=Please fill in the required fields before testing.
+apps.ai.error.test-connection=Failed to test the connection.
+apps.ai.settings.title=Settings
+apps.ai.settings.description=Prompts and behavior applied across all capabilities.
+apps.ai.settings.role-prompt.label=Role prompt
+apps.ai.settings.role-prompt.placeholder=You are dotCMSbot...
+apps.ai.settings.role-prompt.hint=Describes the role the AI plays for content authors.
+apps.ai.settings.text-prompt.label=Text prompt
+apps.ai.settings.text-prompt.placeholder=Use Descriptive writing style.
+apps.ai.settings.image-prompt.label=Image prompt
+apps.ai.settings.image-prompt.placeholder=Use 16:9 aspect ratio.
+apps.ai.settings.image-size.label=Image size
+apps.ai.settings.advanced.header=Advanced {0} settings
+apps.ai.settings.field.embeddingsSplitAtTokens.label=Split into (tokens)
+apps.ai.settings.field.embeddingsSplitAtTokens.hint=Token count used to chunk content before indexing.
+apps.ai.settings.field.embeddingsMinimumTextLength.label=Minimum text length to index
+apps.ai.settings.field.embeddingsMinimumFileSize.label=Minimum file size (bytes)
+apps.ai.settings.field.embeddingsFileExtensions.label=File extensions
+apps.ai.settings.field.embeddingsFileExtensions.hint=Comma-separated, e.g. pdf,doc,docx,txt,html
+apps.ai.settings.field.embeddingsSearchThreshold.label=Search threshold
+apps.ai.settings.field.embeddingsThreads.label=Threads
+apps.ai.settings.field.embeddingsThreadsMax.label=Max threads
+apps.ai.settings.field.embeddingsThreadsQueue.label=Thread queue size
+apps.ai.settings.field.embeddingsCacheTtlSeconds.label=Cache TTL (s)
+apps.ai.settings.field.embeddingsCacheSize.label=Cache size
+apps.ai.settings.field.embeddingsDeleteOldOnUpdate.label=Delete old embeddings on content update
+apps.ai.settings.field.debugLogging.label=Enable verbose debug logging
+apps.ai.additional-properties.label=Additional properties
+apps.ai.additional-properties.key.placeholder=Property name
+apps.ai.additional-properties.value.placeholder=Value
+apps.ai.additional-properties.remove.aria-label=Remove property
+apps.ai.additional-properties.add.button=Add Property
DotAsset=DotAsset
VersionPath=Version Path
IdPath=Id Path
diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
index 83535f224d08..4f72d07600c6 100644
--- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
+++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
@@ -3022,6 +3022,74 @@ paths:
description: default response
tags:
- AI
+ /v1/ai/providers:
+ get:
+ description: "Returns, for every registered dotAI provider, the capabilities\
+ \ it supports (chat/embeddings/image) and the providerConfig fields each supported\
+ \ capability requires or accepts."
+ operationId: listAiProviders
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ResponseEntityAiProviderListView"
+ description: Provider metadata retrieved successfully
+ "401":
+ content:
+ application/json: {}
+ description: Unauthorized - authentication required
+ summary: List dotAI provider configuration metadata
+ tags:
+ - AI
+ /v1/ai/providers/test/{capability}:
+ post:
+ description: "Builds the provider client for the given capability from the posted\
+ \ configuration and issues one minimal real request against the provider (a\
+ \ short chat reply, a one-line embedding, or a single test image). Masked\
+ \ credential fields (\"*****\") in the posted config are resolved against\
+ \ the real value already stored for siteId before testing. Returns success=false\
+ \ with a message on any validation or provider error rather than an HTTP error\
+ \ status, so the caller can always render the result."
+ operationId: testAiProviderConnection
+ parameters:
+ - in: path
+ name: capability
+ required: true
+ schema:
+ type: string
+ - in: query
+ name: siteId
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: string
+ description: Provider config section to test
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ResponseEntityAiTestConnectionView"
+ description: Test executed — check the success field for the outcome
+ "400":
+ content:
+ application/json: {}
+ description: Unknown capability or malformed request body
+ "401":
+ content:
+ application/json: {}
+ description: Unauthorized - authentication required
+ "403":
+ content:
+ application/json: {}
+ description: "Forbidden - requires CMS admin, or access denied to site"
+ summary: Test a dotAI provider connection
+ tags:
+ - AI
/v1/ai/search:
get:
operationId: searchByGet
@@ -31331,6 +31399,43 @@ components:
type: object
additionalProperties:
type: object
+ ProviderField:
+ type: object
+ properties:
+ hint:
+ type: string
+ name:
+ type: string
+ required:
+ type: boolean
+ requiredUnless:
+ type: string
+ type:
+ type: string
+ enum:
+ - STRING
+ - NUMBER
+ - SECRET
+ ProviderMetadata:
+ type: object
+ properties:
+ fields:
+ type: object
+ additionalProperties:
+ type: array
+ items:
+ $ref: "#/components/schemas/ProviderField"
+ provider:
+ type: string
+ supportedCapabilities:
+ type: array
+ items:
+ type: string
+ enum:
+ - CHAT
+ - EMBEDDINGS
+ - IMAGE
+ uniqueItems: true
PublishingEndPoint:
type: object
properties:
@@ -32061,6 +32166,54 @@ components:
type: array
items:
type: string
+ ResponseEntityAiProviderListView:
+ type: object
+ properties:
+ entity:
+ type: array
+ items:
+ $ref: "#/components/schemas/ProviderMetadata"
+ errors:
+ type: array
+ items:
+ $ref: "#/components/schemas/ErrorEntity"
+ i18nMessagesMap:
+ type: object
+ additionalProperties:
+ type: string
+ messages:
+ type: array
+ items:
+ $ref: "#/components/schemas/MessageEntity"
+ pagination:
+ $ref: "#/components/schemas/Pagination"
+ permissions:
+ type: array
+ items:
+ type: string
+ ResponseEntityAiTestConnectionView:
+ type: object
+ properties:
+ entity:
+ $ref: "#/components/schemas/TestConnectionResult"
+ errors:
+ type: array
+ items:
+ $ref: "#/components/schemas/ErrorEntity"
+ i18nMessagesMap:
+ type: object
+ additionalProperties:
+ type: string
+ messages:
+ type: array
+ items:
+ $ref: "#/components/schemas/MessageEntity"
+ pagination:
+ $ref: "#/components/schemas/Pagination"
+ permissions:
+ type: array
+ items:
+ type: string
ResponseEntityApiTokenWithJwtView:
type: object
properties:
@@ -37649,6 +37802,13 @@ components:
type: string
working:
type: boolean
+ TestConnectionResult:
+ type: object
+ properties:
+ message:
+ type: string
+ success:
+ type: boolean
TextAreaField:
type: object
allOf:
diff --git a/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTesterTest.java b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTesterTest.java
new file mode 100644
index 000000000000..124b9414eb70
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderConnectionTesterTest.java
@@ -0,0 +1,188 @@
+package com.dotcms.ai.client.langchain4j;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Regression tests for {@link ProviderConnectionTester#friendlyMessage} (message truncation) and
+ * {@link ProviderConnectionTester#withDefaultTimeoutIfUnset} (bounding an otherwise-unbounded test
+ * call when the posted config omits {@code timeout}).
+ */
+public class ProviderConnectionTesterTest {
+
+ /**
+ * Given an exception with a short message,
+ * When friendlyMessage is called,
+ * Then the message is returned unchanged.
+ */
+ @Test
+ public void test_friendlyMessage_shortMessage_returnsUnchanged() {
+ final String message = "Invalid API key provided";
+
+ assertEquals(message, ProviderConnectionTester.friendlyMessage(new RuntimeException(message)));
+ }
+
+ /**
+ * Given an exception with a null message,
+ * When friendlyMessage is called,
+ * Then the exception's simple class name is returned.
+ */
+ @Test
+ public void test_friendlyMessage_nullMessage_returnsClassName() {
+ assertEquals("RuntimeException", ProviderConnectionTester.friendlyMessage(new RuntimeException()));
+ }
+
+ /**
+ * Given an exception with a blank (whitespace-only) message,
+ * When friendlyMessage is called,
+ * Then the exception's simple class name is returned.
+ */
+ @Test
+ public void test_friendlyMessage_blankMessage_returnsClassName() {
+ assertEquals("IllegalStateException",
+ ProviderConnectionTester.friendlyMessage(new IllegalStateException(" ")));
+ }
+
+ /**
+ * Given an exception whose message is longer than the cap,
+ * When friendlyMessage is called,
+ * Then the result is truncated to the cap length plus the ellipsis suffix.
+ */
+ @Test
+ public void test_friendlyMessage_longMessage_truncatedWithEllipsis() {
+ final String longMessage = "x".repeat(500);
+
+ final String result = ProviderConnectionTester.friendlyMessage(new RuntimeException(longMessage));
+
+ assertTrue(result.endsWith("…"));
+ // 200 chars kept + 1 ellipsis char
+ assertEquals(201, result.length());
+ }
+
+ /**
+ * Given an exception message at exactly the cap length,
+ * When friendlyMessage is called,
+ * Then it is returned unchanged, with no truncation applied.
+ */
+ @Test
+ public void test_friendlyMessage_exactlyAtCap_returnsUnchanged() {
+ final String message = "x".repeat(200);
+
+ assertEquals(message, ProviderConnectionTester.friendlyMessage(new RuntimeException(message)));
+ }
+
+ /**
+ * Given a message one character over the cap,
+ * When friendlyMessage is called,
+ * Then it is truncated.
+ */
+ @Test
+ public void test_friendlyMessage_oneOverCap_truncated() {
+ final String message = "x".repeat(201);
+
+ final String result = ProviderConnectionTester.friendlyMessage(new RuntimeException(message));
+
+ assertFalse(result.equals(message));
+ assertTrue(result.endsWith("…"));
+ }
+
+ /**
+ * Given an exception message containing newlines and repeated whitespace (typical of a
+ * pretty-printed JSON error body),
+ * When friendlyMessage is called,
+ * Then the whitespace is collapsed into single spaces.
+ */
+ @Test
+ public void test_friendlyMessage_multilineMessage_whitespaceCollapsed() {
+ final String message = "Error occurred:\n\n {\n \"code\": 401,\n \"message\": \"bad key\"\n }";
+
+ final String result = ProviderConnectionTester.friendlyMessage(new RuntimeException(message));
+
+ assertFalse(result.contains("\n"));
+ assertTrue(result.contains("Error occurred: { \"code\": 401, \"message\": \"bad key\" }"));
+ }
+
+ /**
+ * Given an exception message with leading/trailing whitespace,
+ * When friendlyMessage is called,
+ * Then the result is trimmed.
+ */
+ @Test
+ public void test_friendlyMessage_leadingTrailingWhitespace_trimmed() {
+ final String message = " Invalid credentials ";
+
+ assertEquals("Invalid credentials", ProviderConnectionTester.friendlyMessage(new RuntimeException(message)));
+ }
+
+ // -------------------------------------------------------------------------
+ // withDefaultTimeoutIfUnset
+ // -------------------------------------------------------------------------
+
+ /**
+ * Given a config with no timeout set,
+ * When withDefaultTimeoutIfUnset is called,
+ * Then the default test timeout is applied.
+ */
+ @Test
+ public void test_withDefaultTimeoutIfUnset_noTimeoutSet_appliesDefault() {
+ final ProviderConfig config = ImmutableProviderConfig.builder()
+ .provider("openai").apiKey("test-key").model("gpt-4o").build();
+
+ final ProviderConfig result = ProviderConnectionTester.withDefaultTimeoutIfUnset(config, Capability.CHAT);
+
+ assertEquals(Integer.valueOf(10), result.timeout());
+ }
+
+ /**
+ * Given an IMAGE-capability config with no timeout set,
+ * When withDefaultTimeoutIfUnset is called,
+ * Then the longer image-specific default is applied instead of the chat/embeddings default —
+ * real image generation routinely takes well past 10s.
+ */
+ @Test
+ public void test_withDefaultTimeoutIfUnset_imageCapability_appliesLongerDefault() {
+ final ProviderConfig config = ImmutableProviderConfig.builder()
+ .provider("openai").apiKey("test-key").model("dall-e-3").build();
+
+ final ProviderConfig result = ProviderConnectionTester.withDefaultTimeoutIfUnset(config, Capability.IMAGE);
+
+ assertEquals(Integer.valueOf(60), result.timeout());
+ }
+
+ /**
+ * Given a config that already sets a timeout,
+ * When withDefaultTimeoutIfUnset is called,
+ * Then the caller's timeout is preserved unchanged — even for IMAGE.
+ */
+ @Test
+ public void test_withDefaultTimeoutIfUnset_timeoutAlreadySet_preservesCallerValue() {
+ final ProviderConfig config = ImmutableProviderConfig.builder()
+ .provider("openai").apiKey("test-key").model("gpt-4o").timeout(45).build();
+
+ final ProviderConfig result = ProviderConnectionTester.withDefaultTimeoutIfUnset(config, Capability.IMAGE);
+
+ assertEquals(Integer.valueOf(45), result.timeout());
+ }
+
+ /**
+ * Given a config with no timeout set,
+ * When withDefaultTimeoutIfUnset is called,
+ * Then every other field is preserved unchanged — only timeout is added.
+ */
+ @Test
+ public void test_withDefaultTimeoutIfUnset_preservesOtherFields() {
+ final ProviderConfig config = ImmutableProviderConfig.builder()
+ .provider("openai").apiKey("test-key").model("gpt-4o").temperature(0.5).build();
+
+ final ProviderConfig result = ProviderConnectionTester.withDefaultTimeoutIfUnset(config, Capability.CHAT);
+
+ assertEquals("openai", result.provider());
+ assertEquals("test-key", result.apiKey());
+ assertEquals("gpt-4o", result.model());
+ assertEquals(Double.valueOf(0.5), result.temperature());
+ }
+
+}
diff --git a/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderMetadataTest.java b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderMetadataTest.java
new file mode 100644
index 000000000000..45e5fcb48eab
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/ai/client/langchain4j/ProviderMetadataTest.java
@@ -0,0 +1,243 @@
+package com.dotcms.ai.client.langchain4j;
+
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Unit tests for {@link LangChain4jModelFactory#listProviderMetadata()} and the per-strategy
+ * {@code supportedCapabilities()}/{@code configFields()} declarations it aggregates.
+ *
+ * The declared fields/capabilities restate knowledge already enforced imperatively in each
+ * strategy's {@code validate()}/builder logic, so nothing in the compiler keeps the two in sync.
+ * The {@code assertMissingRequiredFieldBreaksChatBuild} checks below cross-check that by
+ * construction: for a field the metadata declares required, building without it must actually fail.
+ */
+public class ProviderMetadataTest {
+
+ @Test
+ public void test_listProviderMetadata_returnsAllSevenProviders() {
+ final List metadata = LangChain4jModelFactory.listProviderMetadata();
+ final Set providers = metadata.stream()
+ .map(ProviderMetadata::provider)
+ .collect(Collectors.toSet());
+ assertEquals(7, metadata.size());
+ assertEquals(Set.of("openai", "azure_openai", "bedrock", "vertex_ai", "anthropic", "openrouter", "google_ai"),
+ providers);
+ }
+
+ @Test
+ public void test_listProviderMetadata_capabilitiesMatchKnownSupport() {
+ final Map byProvider = indexByProvider();
+ assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE),
+ byProvider.get("openai").supportedCapabilities());
+ assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE),
+ byProvider.get("azure_openai").supportedCapabilities());
+ assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS),
+ byProvider.get("bedrock").supportedCapabilities());
+ assertEquals(Set.of(Capability.CHAT), byProvider.get("vertex_ai").supportedCapabilities());
+ assertEquals(Set.of(Capability.CHAT), byProvider.get("anthropic").supportedCapabilities());
+ assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS),
+ byProvider.get("openrouter").supportedCapabilities());
+ assertEquals(Set.of(Capability.CHAT, Capability.EMBEDDINGS, Capability.IMAGE),
+ byProvider.get("google_ai").supportedCapabilities());
+ }
+
+ @Test
+ public void test_listProviderMetadata_fieldsKeyedOnlyBySupportedCapabilities() {
+ for (final ProviderMetadata metadata : LangChain4jModelFactory.listProviderMetadata()) {
+ assertEquals(metadata.supportedCapabilities(), metadata.fields().keySet());
+ for (final Capability capability : metadata.supportedCapabilities()) {
+ assertTrue("provider " + metadata.provider() + " declares no fields for " + capability,
+ !metadata.fields().get(capability).isEmpty());
+ }
+ }
+ }
+
+ @Test
+ public void test_configFields_calledForUnsupportedCapability_throws() {
+ assertThrows(UnsupportedOperationException.class, () -> strategyFor("bedrock").configFields(Capability.IMAGE));
+ assertThrows(UnsupportedOperationException.class, () -> strategyFor("vertex_ai").configFields(Capability.EMBEDDINGS));
+ assertThrows(UnsupportedOperationException.class, () -> strategyFor("vertex_ai").configFields(Capability.IMAGE));
+ assertThrows(UnsupportedOperationException.class, () -> strategyFor("anthropic").configFields(Capability.EMBEDDINGS));
+ assertThrows(UnsupportedOperationException.class, () -> strategyFor("anthropic").configFields(Capability.IMAGE));
+ assertThrows(UnsupportedOperationException.class, () -> strategyFor("openrouter").configFields(Capability.IMAGE));
+ }
+
+ // ── Declared-required-field cross-checks (chat capability, common to all 7 providers) ──────
+
+ @Test
+ public void test_openai_chatRequiredFields_missingApiKey_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("openai",
+ ImmutableProviderConfig.builder().provider("openai").model("gpt-4o-mini"), "apiKey");
+ }
+
+ @Test
+ public void test_openai_chatRequiredFields_missingModel_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("openai",
+ ImmutableProviderConfig.builder().provider("openai").apiKey("test-key"), "model");
+ }
+
+ @Test
+ public void test_azureOpenAi_chatRequiredFields_missingApiKey_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("azure_openai",
+ ImmutableProviderConfig.builder().provider("azure_openai").model("gpt-4o")
+ .endpoint("https://my-company.openai.azure.com/"),
+ "apiKey");
+ }
+
+ @Test
+ public void test_azureOpenAi_chatRequiredFields_missingEndpoint_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("azure_openai",
+ ImmutableProviderConfig.builder().provider("azure_openai").model("gpt-4o").apiKey("test-key"),
+ "endpoint");
+ }
+
+ @Test
+ public void test_azureOpenAi_modelAndDeploymentName_declaredOptionalWithCrossHints() {
+ final ProviderMetadata metadata = indexByProvider().get("azure_openai");
+ final List chatFields = metadata.fields().get(Capability.CHAT);
+ final ProviderField model = fieldNamed(chatFields, "model");
+ final ProviderField deploymentName = fieldNamed(chatFields, "deploymentName");
+ assertTrue(!model.required());
+ assertTrue(!deploymentName.required());
+ assertTrue(model.hint().contains("deploymentName"));
+ assertTrue(deploymentName.hint().contains("model"));
+ }
+
+ /**
+ * A client (e.g. the config UI) needs {@code requiredUnless} — not just the hint text — to
+ * enforce the either-or relationship without parsing hint wording. Confirms both directions
+ * of the Azure model/deploymentName pair declare their sibling by field name.
+ */
+ @Test
+ public void test_azureOpenAi_modelAndDeploymentName_declareRequiredUnlessSibling() {
+ final ProviderMetadata metadata = indexByProvider().get("azure_openai");
+ final List chatFields = metadata.fields().get(Capability.CHAT);
+ final ProviderField model = fieldNamed(chatFields, "model");
+ final ProviderField deploymentName = fieldNamed(chatFields, "deploymentName");
+ assertEquals("deploymentName", model.requiredUnless());
+ assertEquals("model", deploymentName.requiredUnless());
+ }
+
+ /**
+ * A field with no either-or relationship (e.g. a plain required field) must declare an empty
+ * {@code requiredUnless}, not null — so clients can treat it as always-falsy without a
+ * null-check.
+ */
+ @Test
+ public void test_openai_apiKey_hasNoRequiredUnless() {
+ final ProviderMetadata metadata = indexByProvider().get("openai");
+ final ProviderField apiKey = fieldNamed(metadata.fields().get(Capability.CHAT), "apiKey");
+ assertEquals("", apiKey.requiredUnless());
+ }
+
+ @Test
+ public void test_bedrock_chatRequiredFields_missingRegion_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("bedrock",
+ ImmutableProviderConfig.builder().provider("bedrock").model("anthropic.claude-3-5-sonnet-20241022-v2:0"),
+ "region");
+ }
+
+ @Test
+ public void test_bedrock_chatRequiredFields_missingModel_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("bedrock",
+ ImmutableProviderConfig.builder().provider("bedrock").region("us-east-1"), "model");
+ }
+
+ @Test
+ public void test_vertexAi_chatRequiredFields_missingProjectId_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("vertex_ai",
+ ImmutableProviderConfig.builder().provider("vertex_ai").model("gemini-1.5-pro").location("us-central1"),
+ "projectId");
+ }
+
+ @Test
+ public void test_vertexAi_chatRequiredFields_missingLocation_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("vertex_ai",
+ ImmutableProviderConfig.builder().provider("vertex_ai").model("gemini-1.5-pro").projectId("my-gcp-project"),
+ "location");
+ }
+
+ @Test
+ public void test_anthropic_chatRequiredFields_missingApiKey_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("anthropic",
+ ImmutableProviderConfig.builder().provider("anthropic").model("claude-sonnet-4-6"), "apiKey");
+ }
+
+ @Test
+ public void test_anthropic_chatRequiredFields_missingModel_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("anthropic",
+ ImmutableProviderConfig.builder().provider("anthropic").apiKey("test-key"), "model");
+ }
+
+ @Test
+ public void test_openRouter_chatRequiredFields_missingModel_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("openrouter",
+ ImmutableProviderConfig.builder().provider("openrouter").apiKey("test-key"), "model");
+ }
+
+ @Test
+ public void test_openRouter_chatRequiredFields_missingApiKey_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("openrouter",
+ ImmutableProviderConfig.builder().provider("openrouter").model("openai/gpt-4o"), "apiKey");
+ }
+
+ @Test
+ public void test_googleAi_chatRequiredFields_missingApiKey_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("google_ai",
+ ImmutableProviderConfig.builder().provider("google_ai").model("gemini-2.0-flash"), "apiKey");
+ }
+
+ @Test
+ public void test_googleAi_chatRequiredFields_missingModel_throws() {
+ assertMissingRequiredFieldBreaksChatBuild("google_ai",
+ ImmutableProviderConfig.builder().provider("google_ai").apiKey("test-key"), "model");
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ /**
+ * Asserts that {@code provider}'s metadata declares {@code missingFieldName} as required for
+ * CHAT, and that the given (deliberately incomplete) builder — missing exactly that field —
+ * indeed fails to build a chat model. Ties the declarative metadata to the imperative
+ * validation every strategy already performs.
+ */
+ private static void assertMissingRequiredFieldBreaksChatBuild(final String provider,
+ final ImmutableProviderConfig.Builder incompleteBuilder,
+ final String missingFieldName) {
+ final ProviderMetadata metadata = indexByProvider().get(provider);
+ final ProviderField field = fieldNamed(metadata.fields().get(Capability.CHAT), missingFieldName);
+ assertTrue(provider + "." + missingFieldName + " must be declared required for CHAT in ProviderMetadata",
+ field.required());
+ assertThrows(IllegalArgumentException.class,
+ () -> LangChain4jModelFactory.buildChatModel(incompleteBuilder.build()));
+ }
+
+ private static ProviderField fieldNamed(final List fields, final String name) {
+ return fields.stream()
+ .filter(f -> f.name().equals(name))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no field named '" + name + "' in " + fields));
+ }
+
+ private static ModelProviderStrategy strategyFor(final String provider) {
+ return LangChain4jModelFactory.STRATEGIES.stream()
+ .filter(s -> s.providerName().equals(provider))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private static Map indexByProvider() {
+ return LangChain4jModelFactory.listProviderMetadata().stream()
+ .collect(Collectors.toMap(ProviderMetadata::provider, m -> m));
+ }
+
+}
diff --git a/dotCMS/src/test/java/com/dotcms/ai/rest/AiProviderResourceTest.java b/dotCMS/src/test/java/com/dotcms/ai/rest/AiProviderResourceTest.java
new file mode 100644
index 000000000000..98dec60759ce
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/ai/rest/AiProviderResourceTest.java
@@ -0,0 +1,302 @@
+package com.dotcms.ai.rest;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Regression tests for the masked-credential exfiltration/SSRF guard in
+ * {@link AiProviderResource#testConnection}: a masked credential (e.g. {@code "apiKey":
+ * "*****"}) must only resolve to the real stored secret when the posted {@code provider} and
+ * {@code endpoint} match what's actually stored — otherwise a caller could pair a masked field
+ * (obtainable from any {@code GET}) with an attacker-controlled {@code endpoint} and have the
+ * server send the real secret there.
+ */
+public class AiProviderResourceTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ // -------------------------------------------------------------------------
+ // resolveMaskedCredentials
+ // -------------------------------------------------------------------------
+
+ /**
+ * Given a blank stored providerConfig,
+ * When resolveMaskedCredentials is called,
+ * Then the posted body is returned unchanged.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_blankStored_returnsBodyUnchanged() {
+ final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}";
+
+ assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, "", "chat"));
+ assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, null, "chat"));
+ }
+
+ /**
+ * Given a posted body with no masked sentinel,
+ * When resolveMaskedCredentials is called,
+ * Then the posted body is returned unchanged, even though a stored config exists.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_noMaskedValue_returnsBodyUnchanged() {
+ final String body = "{\"provider\":\"openai\",\"apiKey\":\"sk-real\"}";
+ final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-stored\"}}";
+
+ assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, stored, "chat"));
+ }
+
+ /**
+ * Given a masked apiKey and a posted provider/endpoint that match the stored section,
+ * When resolveMaskedCredentials is called,
+ * Then the real stored apiKey is restored.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_matchingProviderAndEndpoint_restoresCredential() {
+ final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\",\"model\":\"gpt-4o\"}";
+ final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\",\"model\":\"gpt-3.5\"}}";
+
+ final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat");
+
+ assertTrue(result.contains("sk-real-key"));
+ assertTrue(result.contains("gpt-4o")); // non-guarded field from the posted body still wins
+ assertFalse(result.contains("*****"));
+ }
+
+ /**
+ * Given a masked apiKey posted with a DIFFERENT provider than what's stored for that
+ * capability (e.g. the UI carried over a masked value across a provider switch),
+ * When resolveMaskedCredentials is called,
+ * Then the sentinel is left in place rather than resolving to the wrong provider's secret.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_providerMismatch_leavesSentinelInPlace() {
+ final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}";
+ final String stored = "{\"chat\":{\"provider\":\"vertex_ai\",\"apiKey\":\"sk-real-key\"}}";
+
+ final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat");
+
+ assertTrue(result.contains("*****"));
+ assertFalse(result.contains("sk-real-key"));
+ }
+
+ /**
+ * Given a masked apiKey posted alongside an endpoint that differs from the stored endpoint
+ * (the exfiltration/SSRF attempt: pair a masked credential with an attacker-controlled host),
+ * When resolveMaskedCredentials is called,
+ * Then the sentinel is left in place rather than sending the real secret to the new endpoint.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_endpointMismatch_leavesSentinelInPlace() {
+ final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\","
+ + "\"endpoint\":\"https://attacker.example/v1\"}";
+ final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\","
+ + "\"endpoint\":\"https://api.openai.com/v1\"}}";
+
+ final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat");
+
+ assertTrue(result.contains("*****"));
+ assertFalse(result.contains("sk-real-key"));
+ }
+
+ /**
+ * Given a masked apiKey where neither the posted body nor the stored config sets an endpoint
+ * (the common case — no custom endpoint override),
+ * When resolveMaskedCredentials is called,
+ * Then the credential still resolves, since a missing endpoint on both sides is a match.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_neitherHasEndpoint_stillResolves() {
+ final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}";
+ final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\"}}";
+
+ final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat");
+
+ assertTrue(result.contains("sk-real-key"));
+ }
+
+ /**
+ * Given a stored providerConfig with no section for the requested capability,
+ * When resolveMaskedCredentials is called,
+ * Then the posted body is returned unchanged.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_missingSection_returnsBodyUnchanged() {
+ final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}";
+ final String stored = "{\"embeddings\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\"}}";
+
+ final String result = AiProviderResource.resolveMaskedCredentials(body, stored, "chat");
+
+ assertTrue(result.contains("*****"));
+ }
+
+ /**
+ * Given a stored section that isn't a JSON object (defensively malformed data),
+ * When resolveMaskedCredentials is called,
+ * Then the posted body is returned unchanged.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_sectionNotAnObject_returnsBodyUnchanged() {
+ final String body = "{\"provider\":\"openai\",\"apiKey\":\"*****\"}";
+ final String stored = "{\"chat\":\"not-an-object\"}";
+
+ assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, stored, "chat"));
+ }
+
+ /**
+ * Given a posted body that isn't valid JSON,
+ * When resolveMaskedCredentials is called,
+ * Then the posted body is returned unchanged rather than throwing.
+ */
+ @Test
+ public void test_resolveMaskedCredentials_invalidBody_returnsBodyUnchanged() {
+ final String body = "not-valid-json-*****";
+ final String stored = "{\"chat\":{\"provider\":\"openai\",\"apiKey\":\"sk-real-key\"}}";
+
+ assertEquals(body, AiProviderResource.resolveMaskedCredentials(body, stored, "chat"));
+ }
+
+ // -------------------------------------------------------------------------
+ // targetsStoredDestination
+ // -------------------------------------------------------------------------
+
+ /**
+ * Given incoming and stored nodes with the same provider and endpoint,
+ * When targetsStoredDestination is called,
+ * Then it returns true.
+ */
+ @Test
+ public void test_targetsStoredDestination_matchingProviderAndEndpoint_returnsTrue() throws Exception {
+ final JsonNode incoming = node("{\"provider\":\"openai\",\"endpoint\":\"https://api.openai.com/v1\"}");
+ final JsonNode stored = node("{\"provider\":\"openai\",\"endpoint\":\"https://api.openai.com/v1\"}");
+
+ assertTrue(AiProviderResource.targetsStoredDestination(incoming, stored));
+ }
+
+ /**
+ * Given incoming and stored nodes with different providers,
+ * When targetsStoredDestination is called,
+ * Then it returns false.
+ */
+ @Test
+ public void test_targetsStoredDestination_differentProvider_returnsFalse() throws Exception {
+ final JsonNode incoming = node("{\"provider\":\"openai\"}");
+ final JsonNode stored = node("{\"provider\":\"vertex_ai\"}");
+
+ assertFalse(AiProviderResource.targetsStoredDestination(incoming, stored));
+ }
+
+ /**
+ * Given incoming and stored nodes with the same provider but different endpoints,
+ * When targetsStoredDestination is called,
+ * Then it returns false.
+ */
+ @Test
+ public void test_targetsStoredDestination_differentEndpoint_returnsFalse() throws Exception {
+ final JsonNode incoming = node("{\"provider\":\"openai\",\"endpoint\":\"https://attacker.example\"}");
+ final JsonNode stored = node("{\"provider\":\"openai\",\"endpoint\":\"https://api.openai.com/v1\"}");
+
+ assertFalse(AiProviderResource.targetsStoredDestination(incoming, stored));
+ }
+
+ /**
+ * Given incoming and stored nodes where neither sets an endpoint,
+ * When targetsStoredDestination is called,
+ * Then it returns true, since a missing endpoint on both sides matches.
+ */
+ @Test
+ public void test_targetsStoredDestination_neitherHasEndpoint_returnsTrue() throws Exception {
+ final JsonNode incoming = node("{\"provider\":\"openai\"}");
+ final JsonNode stored = node("{\"provider\":\"openai\"}");
+
+ assertTrue(AiProviderResource.targetsStoredDestination(incoming, stored));
+ }
+
+ /**
+ * Given an incoming node that sets an endpoint while the stored node has none,
+ * When targetsStoredDestination is called,
+ * Then it returns false, since that's exactly the "point the secret at a new host" case.
+ */
+ @Test
+ public void test_targetsStoredDestination_onlyIncomingHasEndpoint_returnsFalse() throws Exception {
+ final JsonNode incoming = node("{\"provider\":\"openai\",\"endpoint\":\"https://attacker.example\"}");
+ final JsonNode stored = node("{\"provider\":\"openai\"}");
+
+ assertFalse(AiProviderResource.targetsStoredDestination(incoming, stored));
+ }
+
+ // -------------------------------------------------------------------------
+ // textEquals
+ // -------------------------------------------------------------------------
+
+ /**
+ * Given two nodes with equal text values,
+ * When textEquals is called,
+ * Then it returns true.
+ */
+ @Test
+ public void test_textEquals_equalValues_returnsTrue() throws Exception {
+ final JsonNode a = node("{\"v\":\"openai\"}").get("v");
+ final JsonNode b = node("{\"v\":\"openai\"}").get("v");
+
+ assertTrue(AiProviderResource.textEquals(a, b));
+ }
+
+ /**
+ * Given two nodes with different text values,
+ * When textEquals is called,
+ * Then it returns false.
+ */
+ @Test
+ public void test_textEquals_differentValues_returnsFalse() throws Exception {
+ final JsonNode a = node("{\"v\":\"openai\"}").get("v");
+ final JsonNode b = node("{\"v\":\"vertex_ai\"}").get("v");
+
+ assertFalse(AiProviderResource.textEquals(a, b));
+ }
+
+ /**
+ * Given both nodes are null (field absent on both sides),
+ * When textEquals is called,
+ * Then it returns true.
+ */
+ @Test
+ public void test_textEquals_bothNull_returnsTrue() {
+ assertTrue(AiProviderResource.textEquals(null, null));
+ }
+
+ /**
+ * Given one node is null (field absent) and the other holds a value,
+ * When textEquals is called,
+ * Then it returns false.
+ */
+ @Test
+ public void test_textEquals_oneNull_returnsFalse() throws Exception {
+ final JsonNode b = node("{\"v\":\"openai\"}").get("v");
+
+ assertFalse(AiProviderResource.textEquals(null, b));
+ assertFalse(AiProviderResource.textEquals(b, null));
+ }
+
+ /**
+ * Given both nodes are the JSON null literal (field present but explicitly null),
+ * When textEquals is called,
+ * Then it returns true, since a JSON null is treated the same as an absent field.
+ */
+ @Test
+ public void test_textEquals_bothJsonNullLiteral_returnsTrue() throws Exception {
+ final JsonNode a = node("{\"v\":null}").get("v");
+ final JsonNode b = node("{\"v\":null}").get("v");
+
+ assertTrue(AiProviderResource.textEquals(a, b));
+ }
+
+ private static JsonNode node(final String json) throws Exception {
+ return MAPPER.readTree(json);
+ }
+
+}