diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIClient.java new file mode 100644 index 000000000000..5393bcaa35ac --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIClient.java @@ -0,0 +1,108 @@ +package com.dotcms.ai.client; + +import com.dotcms.ai.domain.AIProvider; +import com.dotcms.ai.domain.AIRequest; +import com.dotcms.ai.domain.JSONObjectAIRequest; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpUriRequest; + +import javax.ws.rs.HttpMethod; +import java.io.OutputStream; +import java.io.Serializable; + +/** + * Interface representing an AI client capable of sending requests to an AI service. + * + *

+ * This interface defines methods for obtaining the AI provider and sending requests + * to the AI service. Implementations of this interface should handle the specifics + * of interacting with the AI service, including request formatting and response handling. + *

+ * + *

+ * The interface also provides a NOOP implementation that throws an + * {@link UnsupportedOperationException} for all operations. + *

+ * + * @author vico + */ +public interface AIClient { + + AIClient NOOP = new AIClient() { + @Override + public AIProvider getProvider() { + return AIProvider.NONE; + } + + @Override + public void sendRequest(final AIRequest request, final OutputStream output) { + throwUnsupported(); + } + + private void throwUnsupported() { + throw new UnsupportedOperationException("Noop client does not support sending requests"); + } + }; + + /** + * Resolves the appropriate HTTP method for the given method name and URL. + * + * @param method the HTTP method name (e.g., "GET", "POST", "PUT", "DELETE", "patch") + * @param url the URL to which the request will be sent + * @return the corresponding {@link HttpUriRequest} for the given method and URL + */ + static HttpUriRequest resolveMethod(final String method, final String url) { + switch(method) { + case HttpMethod.POST: + return new HttpPost(url); + case HttpMethod.PUT: + return new HttpPut(url); + case HttpMethod.DELETE: + return new HttpDelete(url); + case "patch": + return new HttpPatch(url); + case HttpMethod.GET: + default: + return new HttpGet(url); + } + } + + /** + * Validates and casts the given AI request to a {@link JSONObjectAIRequest}. + * + * @param the type of the request payload + * @param request the AI request to be validated and cast + * @return the validated and cast {@link JSONObjectAIRequest} + * @throws UnsupportedOperationException if the request is not an instance of {@link JSONObjectAIRequest} + */ + static JSONObjectAIRequest useRequestOrThrow(final AIRequest request) { + // When we get rid of JSONObject usage, we can remove this check + if (request instanceof JSONObjectAIRequest) { + return (JSONObjectAIRequest) request; + } + + throw new UnsupportedOperationException("Only JSONObjectAIRequest (JSONObject) is supported"); + } + + /** + * Returns the AI provider associated with this client. + * + * @return the AI provider + */ + AIProvider getProvider(); + + /** + * Sends the given AI request to the AI service and writes the response to the provided output stream. + * + * @param the type of the request payload + * @param request the AI request to be sent + * @param output the output stream to which the response will be written + * @throws Exception if any error occurs during the request execution + */ + void sendRequest(AIRequest request, OutputStream output); + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIClientStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIClientStrategy.java new file mode 100644 index 000000000000..f015c5f83c13 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIClientStrategy.java @@ -0,0 +1,42 @@ +package com.dotcms.ai.client; + +import com.dotcms.ai.domain.AIRequest; +import com.dotcms.ai.domain.AIResponse; + +import java.io.OutputStream; +import java.io.Serializable; + +/** + * Interface representing a strategy for handling AI client requests and responses. + * + *

+ * This interface defines a method for applying a strategy to an AI client request, + * allowing for different handling mechanisms to be implemented. The NOOP strategy + * is provided as a default implementation that performs no operations. + *

+ * + *

+ * Implementations of this interface should define how to process the AI request + * and handle the response, potentially writing the response to an output stream. + *

+ * + * @author vico + */ +public interface AIClientStrategy { + + AIClientStrategy NOOP = (client, handler, request, output) -> AIResponse.builder().build(); + + /** + * Applies the strategy to the given AI client request and handles the response. + * + * @param client the AI client to which the request is sent + * @param handler the response evaluator to handle the response + * @param request the AI request to be processed + * @param output the output stream to which the response will be written + */ + void applyStrategy(AIClient client, + AIResponseEvaluator handler, + AIRequest request, + OutputStream output); + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIDefaultStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIDefaultStrategy.java new file mode 100644 index 000000000000..22c3a5aec788 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIDefaultStrategy.java @@ -0,0 +1,34 @@ +package com.dotcms.ai.client; + +import com.dotcms.ai.domain.AIRequest; + +import java.io.OutputStream; +import java.io.Serializable; + +/** + * Default implementation of the {@link AIClientStrategy} interface. + * + *

+ * This class provides a default strategy for handling AI client requests by + * directly sending the request using the provided AI client and writing the + * response to the given output stream. + *

+ * + *

+ * The default strategy does not perform any additional processing or handling + * of the request or response, delegating the entire operation to the AI client. + *

+ * + * @author vico + */ +public class AIDefaultStrategy implements AIClientStrategy { + + @Override + public void applyStrategy(final AIClient client, + final AIResponseEvaluator handler, + final AIRequest request, + final OutputStream output) { + client.sendRequest(request, output); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIProxiedClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIProxiedClient.java new file mode 100644 index 000000000000..bd6020317f2a --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIProxiedClient.java @@ -0,0 +1,86 @@ +package com.dotcms.ai.client; + +import com.dotcms.ai.domain.AIRequest; +import com.dotcms.ai.domain.AIResponse; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.Serializable; +import java.util.Optional; + +/** + * A proxy client for interacting with an AI service using a specified strategy. + * + *

+ * This class provides a mechanism to send requests to an AI service through a proxied client, + * applying a given strategy for handling the requests and responses. It supports a NOOP implementation + * that performs no operations. + *

+ * + *

+ * The class allows for the creation of proxied clients with different strategies and response evaluators, + * enabling flexible handling of AI service interactions. + *

+ * + * @author vico + */ +public class AIProxiedClient { + + public static final AIProxiedClient NOOP = new AIProxiedClient(null, AIClientStrategy.NOOP, null); + + private final AIClient client; + private final AIClientStrategy strategy; + private final AIResponseEvaluator responseEvaluator; + + private AIProxiedClient(final AIClient client, + final AIClientStrategy strategy, + final AIResponseEvaluator responseEvaluator) { + this.client = client; + this.strategy = strategy; + this.responseEvaluator = responseEvaluator; + } + + /** + * Creates an AIProxiedClient with the specified client, strategy, and response evaluator. + * + * @param client the AI client to be proxied + * @param strategy the strategy to be applied for handling requests and responses + * @param responseParser the response evaluator to process responses + * @return a new instance of AIProxiedClient + */ + public static AIProxiedClient of(final AIClient client, + final AIProxyStrategy strategy, + final AIResponseEvaluator responseParser) { + return new AIProxiedClient(client, strategy.getStrategy(), responseParser); + } + + /** + * Creates an AIProxiedClient with the specified client and strategy. + * + * @param client the AI client to be proxied + * @param strategy the strategy to be applied for handling requests and responses + * @return a new instance of AIProxiedClient + */ + public static AIProxiedClient of(final AIClient client, final AIProxyStrategy strategy) { + return of(client, strategy, null); + } + + /** + * Sends the given AI request to the AI service and writes the response to the provided output stream. + * + * @param the type of the request payload + * @param request the AI request to be sent + * @param output the output stream to which the response will be written + * @return the AI response + */ + public AIResponse sendToAI(final AIRequest request, final OutputStream output) { + final OutputStream finalOutput = Optional.ofNullable(output).orElseGet(ByteArrayOutputStream::new); + + strategy.applyStrategy(client, responseEvaluator, request, finalOutput); + + return Optional.ofNullable(output) + .map(out -> AIResponse.EMPTY) + .orElseGet(() -> AIResponse.builder().withResponse(finalOutput.toString()).build()); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIProxyClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIProxyClient.java new file mode 100644 index 000000000000..30dd2e1a4690 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIProxyClient.java @@ -0,0 +1,113 @@ +package com.dotcms.ai.client; + +import com.dotcms.ai.client.openai.OpenAIClient; +import com.dotcms.ai.client.openai.OpenAIResponseEvaluator; +import com.dotcms.ai.domain.AIProvider; +import com.dotcms.ai.domain.AIRequest; +import com.dotcms.ai.domain.AIResponse; +import io.vavr.Lazy; + +import java.io.OutputStream; +import java.io.Serializable; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; + +/** + * A proxy client for managing and interacting with multiple AI service providers. + * + *

+ * This class provides a mechanism to send requests to various AI service providers through proxied clients, + * applying different strategies for handling the requests and responses. It supports adding new clients and + * switching between different AI providers. + *

+ * + *

+ * The class allows for flexible handling of AI service interactions by maintaining a map of proxied clients + * and providing methods to send requests to the current or specified provider. + *

+ * + * @author vico + */ +public class AIProxyClient { + + private static final Lazy INSTANCE = Lazy.of(AIProxyClient::new); + + private final ConcurrentMap proxiedClients; + private final AtomicReference currentProvider; + + private AIProxyClient() { + proxiedClients = new ConcurrentHashMap<>(); + addClient( + AIProvider.OPEN_AI, + AIProxiedClient.of(OpenAIClient.get(), AIProxyStrategy.MODEL_FALLBACK, OpenAIResponseEvaluator.get())); + currentProvider = new AtomicReference<>(AIProvider.OPEN_AI); + } + + public static AIProxyClient get() { + return INSTANCE.get(); + } + + /** + * Adds a proxied client for the specified AI provider. + * + * @param provider the AI provider for which the client is added + * @param client the proxied client to be added + */ + public void addClient(final AIProvider provider, final AIProxiedClient client) { + proxiedClients.put(provider, client); + } + + /** + * Sends the given AI request to the specified AI provider and writes the response to the provided output stream. + * + * @param provider the AI provider to which the request is sent + * @param request the AI request to be sent + * @param output the output stream to which the response will be written + * @return the AI response + */ + public AIResponse callToAI(final AIProvider provider, + final AIRequest request, + final OutputStream output) { + return Optional.ofNullable(proxiedClients.getOrDefault(provider, AIProxiedClient.NOOP)) + .map(client -> client.sendToAI(request, output)) + .orElse(AIResponse.EMPTY); + } + + /** + * Sends the given AI request to the specified AI provider. + * + * @param the type of the request payload + * @param provider the AI provider to which the request is sent + * @param request the AI request to be sent + * @return the AI response + */ + public AIResponse callToAI(final AIProvider provider, final AIRequest request) { + return callToAI(provider, request, null); + } + + /** + * Sends the given AI request to the current AI provider and writes the response to the provided output stream. + * + * @param the type of the request payload + * @param request the AI request to be sent + * @param output the output stream to which the response will be written + * @return the AI response + */ + public AIResponse callToAI(final AIRequest request, final OutputStream output) { + return callToAI(currentProvider.get(), request, output); + } + + /** + * Sends the given AI request to the current AI provider. + * + * @param the type of the request payload + * @param request the AI request to be sent + * @return the AI response + */ + public AIResponse callToAI(final AIRequest request) { + return callToAI(request, null); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIProxyStrategy.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIProxyStrategy.java new file mode 100644 index 000000000000..1040f3516cf6 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIProxyStrategy.java @@ -0,0 +1,36 @@ +package com.dotcms.ai.client; + +/** + * Enumeration representing different strategies for proxying AI client requests. + * + *

+ * This enum provides different strategies for handling AI client requests, including + * a default strategy and a model fallback strategy. Each strategy is associated with + * an implementation of the {@link AIClientStrategy} interface. + *

+ * + *

+ * The strategies can be used to customize the behavior of AI client interactions, + * allowing for flexible handling of requests and responses. + *

+ * + * @author vico + */ +public enum AIProxyStrategy { + + DEFAULT(new AIDefaultStrategy()), + // TODO: pr-split -> uncomment this line + //MODEL_FALLBACK(new AIModelFallbackStrategy()); + MODEL_FALLBACK(null); + + private final AIClientStrategy strategy; + + AIProxyStrategy(final AIClientStrategy strategy) { + this.strategy = strategy; + } + + public AIClientStrategy getStrategy() { + return strategy; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/AIResponseEvaluator.java b/dotCMS/src/main/java/com/dotcms/ai/client/AIResponseEvaluator.java new file mode 100644 index 000000000000..d7f8d2ba5ce4 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/AIResponseEvaluator.java @@ -0,0 +1,36 @@ +package com.dotcms.ai.client; + +import com.dotcms.ai.domain.AIResponseData; + +/** + * Interface for evaluating AI responses. + * It provides methods to process responses and exceptions, updating the provided metadata. + * + *

Methods:

+ *
    + *
  • \fromResponse\ - Processes a response string and updates the metadata.
  • + *
  • \fromThrowable\ - Processes an exception and updates the metadata.
  • + *
+ * + * @author vico + */ +public interface AIResponseEvaluator { + + /** + * Processes a response string and updates the metadata. + * + * @param response the response string to process + * @param metadata the metadata to update based on the response + * @param jsonExpected flag for expecting the response to be a JSON + */ + void fromResponse(String response, AIResponseData metadata, boolean jsonExpected); + + /** + * Processes an exception and updates the metadata. + * + * @param exception the exception to process + * @param metadata the metadata to update based on the exception + */ + void fromException(Throwable exception, AIResponseData metadata); + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIClient.java b/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIClient.java new file mode 100644 index 000000000000..417df01f5499 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIClient.java @@ -0,0 +1,166 @@ +package com.dotcms.ai.client.openai; + +import com.dotcms.ai.AiKeys; +import com.dotcms.ai.app.AIModel; +import com.dotcms.ai.app.AppConfig; +import com.dotcms.ai.app.AppKeys; +import com.dotcms.ai.client.AIClient; +import com.dotcms.ai.domain.AIProvider; +import com.dotcms.ai.domain.AIRequest; +import com.dotcms.ai.domain.JSONObjectAIRequest; +import com.dotcms.ai.domain.Model; +import com.dotcms.ai.exception.DotAIAppConfigDisabledException; +import com.dotcms.ai.exception.DotAIClientConnectException; +import com.dotcms.ai.exception.DotAIModelNotFoundException; +import com.dotcms.ai.exception.DotAIModelNotOperationalException; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.json.JSONObject; +import io.vavr.Lazy; +import io.vavr.Tuple; +import io.vavr.Tuple2; +import io.vavr.control.Try; +import org.apache.http.HttpHeaders; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + +import javax.ws.rs.core.MediaType; +import java.io.BufferedInputStream; +import java.io.OutputStream; +import java.io.Serializable; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Implementation of the {@link AIClient} interface for interacting with the OpenAI service. + * + *

+ * This class provides methods to send requests to the OpenAI service and handle responses. + * It includes functionality to manage rate limiting and ensure that models are operational + * before sending requests. + *

+ * + *

+ * The class uses a singleton pattern to ensure a single instance of the client is used + * throughout the application. It also maintains a record of the last REST call for each + * model to enforce rate limiting. + *

+ * + * @auhor vico + */ +public class OpenAIClient implements AIClient { + + private static final Lazy INSTANCE = Lazy.of(OpenAIClient::new); + + private final ConcurrentHashMap lastRestCall; + + public static OpenAIClient get() { + return INSTANCE.get(); + } + + private OpenAIClient() { + lastRestCall = new ConcurrentHashMap<>(); + } + + /** + * {@inheritDoc} + */ + @Override + public AIProvider getProvider() { + return AIProvider.OPEN_AI; + } + + /** + * {@inheritDoc} + */ + @Override + public void sendRequest(final AIRequest request, final OutputStream output) { + final JSONObjectAIRequest jsonRequest = AIClient.useRequestOrThrow(request); + final AppConfig appConfig = jsonRequest.getConfig(); + + AppConfig.debugLogger( + OpenAIClient.class, + () -> String.format( + "Posting to [%s] with method [%s]%s with app config:%s%s the payload: %s", + jsonRequest.getUrl(), + jsonRequest.getMethod(), + System.lineSeparator(), + appConfig.toString(), + System.lineSeparator(), + jsonRequest.payloadToString())); + + if (!appConfig.isEnabled()) { + AppConfig.debugLogger(OpenAIClient.class, () -> "App dotAI is not enabled and will not send request."); + throw new DotAIAppConfigDisabledException("App dotAI config without API urls or API key"); + } + + final JSONObject payload = jsonRequest.getPayload(); + final String modelName = Optional + .ofNullable(payload.optString(AiKeys.MODEL)) + .orElseThrow(() -> new DotAIModelNotFoundException("Model is not present in the request")); + // TODO: pr-split -> uncomment this line + //final Tuple2 modelTuple = appConfig.resolveModelOrThrow(modelName, jsonRequest.getType()); + final Tuple2 modelTuple = Tuple.of(null, null); + final AIModel aiModel = modelTuple._1; + + if (!modelTuple._2.isOperational()) { + AppConfig.debugLogger( + getClass(), + () -> String.format("Resolved model [%s] is not operational, avoiding its usage", modelName)); + throw new DotAIModelNotOperationalException(String.format("Model [%s] is not operational", modelName)); + } + + final long sleep = lastRestCall.computeIfAbsent(aiModel, m -> 0L) + + aiModel.minIntervalBetweenCalls() + - System.currentTimeMillis(); + if (sleep > 0) { + Logger.info( + this, + "Rate limit:" + + aiModel.getApiPerMinute() + + "/minute, or 1 every " + + aiModel.minIntervalBetweenCalls() + + "ms. Sleeping:" + + sleep); + Try.run(() -> Thread.sleep(sleep)); + } + + lastRestCall.put(aiModel, System.currentTimeMillis()); + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + final StringEntity jsonEntity = new StringEntity(payload.toString(), ContentType.APPLICATION_JSON); + final HttpUriRequest httpRequest = AIClient.resolveMethod(jsonRequest.getMethod(), jsonRequest.getUrl()); + httpRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON); + httpRequest.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + appConfig.getApiKey()); + + if (!payload.getAsMap().isEmpty()) { + Try.run(() -> ((HttpEntityEnclosingRequestBase) httpRequest).setEntity(jsonEntity)); + } + + try (CloseableHttpResponse response = httpClient.execute(httpRequest)) { + final BufferedInputStream in = new BufferedInputStream(response.getEntity().getContent()); + final byte[] buffer = new byte[1024]; + int len; + while ((len = in.read(buffer)) != -1) { + output.write(buffer, 0, len); + output.flush(); + } + } + } catch (Exception e) { + if (appConfig.getConfigBoolean(AppKeys.DEBUG_LOGGING)){ + Logger.warn(this, "INVALID REQUEST: " + e.getMessage(), e); + } else { + Logger.warn(this, "INVALID REQUEST: " + e.getMessage()); + } + + Logger.warn(this, " - " + jsonRequest.getMethod() + " : " + payload); + + throw new DotAIClientConnectException("Error while sending request to OpenAI", e); + } + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIResponseEvaluator.java b/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIResponseEvaluator.java new file mode 100644 index 000000000000..d7c3f84cf7cf --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/client/openai/OpenAIResponseEvaluator.java @@ -0,0 +1,91 @@ +package com.dotcms.ai.client.openai; + +import com.dotcms.ai.AiKeys; +import com.dotcms.ai.client.AIResponseEvaluator; +import com.dotcms.ai.domain.AIResponseData; +import com.dotcms.ai.domain.ModelStatus; +import com.dotcms.ai.exception.DotAIModelNotFoundException; +import com.dotcms.ai.exception.DotAIModelNotOperationalException; +import com.dotmarketing.exception.DotRuntimeException; +import com.dotmarketing.util.json.JSONObject; +import io.vavr.Lazy; + +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Evaluates AI responses from OpenAI and updates the provided metadata. + * This class implements the singleton pattern and provides methods to process responses and exceptions. + * + *

Methods:

+ *
    + *
  • \fromResponse\ - Processes a response string and updates the metadata.
  • + *
  • \fromThrowable\ - Processes an exception and updates the metadata.
  • + *
+ * + * @author vico + */ +public class OpenAIResponseEvaluator implements AIResponseEvaluator { + + private static final String JSON_ERROR_FIELD = "\"error\":"; + private static final Lazy INSTANCE = Lazy.of(OpenAIResponseEvaluator::new); + + public static OpenAIResponseEvaluator get() { + return INSTANCE.get(); + } + + private OpenAIResponseEvaluator() { + } + + /** + * {@inheritDoc} + */ + @Override + public void fromResponse(final String response, final AIResponseData metadata, final boolean jsonExpected) { + Optional.ofNullable(response) + .ifPresent(resp -> { + if (jsonExpected || resp.contains(JSON_ERROR_FIELD)) { + final JSONObject jsonResponse = new JSONObject(resp); + if (jsonResponse.has(AiKeys.ERROR)) { + final JSONObject error = jsonResponse.getJSONObject(AiKeys.ERROR); + final String message = error.getString(AiKeys.MESSAGE); + metadata.setError(message); + metadata.setStatus(resolveStatus(message)); + } + } + }); + } + + /** + * {@inheritDoc} + */ + @Override + public void fromException(final Throwable exception, final AIResponseData metadata) { + metadata.setError(exception.getMessage()); + metadata.setStatus(resolveStatus(exception)); + metadata.setException(exception instanceof DotRuntimeException + ? (DotRuntimeException) exception + : new DotRuntimeException(exception)); + } + + private ModelStatus resolveStatus(final String error) { + if (error.contains("has been deprecated")) { + return ModelStatus.DECOMMISSIONED; + } else if (error.contains("does not exist or you do not have access to it")) { + return ModelStatus.INVALID; + } else { + return ModelStatus.UNKNOWN; + } + } + + private ModelStatus resolveStatus(final Throwable throwable) { + if (Stream + .of(DotAIModelNotFoundException.class, DotAIModelNotOperationalException.class) + .anyMatch(exception -> exception.isInstance(throwable))) { + return ModelStatus.INVALID; + } else { + return ModelStatus.UNKNOWN; + } + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/AIProvider.java b/dotCMS/src/main/java/com/dotcms/ai/domain/AIProvider.java new file mode 100644 index 000000000000..9e844d47619f --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/domain/AIProvider.java @@ -0,0 +1,35 @@ +package com.dotcms.ai.domain; + +/** + * Enumeration representing different AI service providers. + * + *

+ * This enum defines various AI service providers that can be used within the application. + * Each provider is associated with a specific name that identifies the AI service. + *

+ * + *

+ * The providers can be used to configure and manage interactions with different AI services, + * allowing for flexible integration and switching between multiple AI providers. + *

+ * + * @author vico + */ +public enum AIProvider { + + NONE("None"), + OPEN_AI("OpenAI"), + BEDROCK("Amazon Bedrock"), + GEMINI("Google Gemini"); + + private final String provider; + + AIProvider(final String provider) { + this.provider = provider; + } + + public String getProvider() { + return provider; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/AIRequest.java b/dotCMS/src/main/java/com/dotcms/ai/domain/AIRequest.java new file mode 100644 index 000000000000..2635267c3e2a --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/domain/AIRequest.java @@ -0,0 +1,219 @@ +package com.dotcms.ai.domain; + +import com.dotcms.ai.app.AIModelType; +import com.dotcms.ai.app.AppConfig; + +import javax.ws.rs.HttpMethod; +import java.io.Serializable; + +/** + * Represents a request to an AI service. + * + *

+ * This class encapsulates the details of an AI request, including the URL, HTTP method, + * configuration, model type, payload, and user ID. It provides methods to create and + * configure AI requests for different model types such as text, image, and embeddings. + *

+ * + * @param the type of the request payload + * @author vico + */ +public class AIRequest { + + private final String url; + private final String method; + private final AppConfig config; + private final AIModelType type; + private final T payload; + private final String userId; + + > AIRequest(final Builder builder) { + this.url = builder.url; + this.method = builder.method; + this.config = builder.config; + this.type = builder.type; + this.payload = builder.payload; + this.userId = builder.userId; + } + + /** + * Creates a quick text AI request with the specified configuration, payload, and user ID. + * + * @param appConfig the application configuration + * @param payload the request payload + * @param userId the user ID + * @param the type of the request payload + * @param the type of the AIRequest + * @return a new AIRequest instance + */ + public static > R quickText(final AppConfig appConfig, + final T payload, + final String userId) { + return quick(AIModelType.TEXT, appConfig, payload, userId); + } + + /** + * Creates a quick image AI request with the specified configuration, payload, and user ID. + * + * @param appConfig the application configuration + * @param payload the request payload + * @param userId the user ID + * @param the type of the request payload + * @param the type of the AIRequest + * @return a new AIRequest instance + */ + public static > R quickImage(final AppConfig appConfig, + final T payload, + final String userId) { + return quick(AIModelType.IMAGE, appConfig, payload, userId); + } + + /** + * Creates a quick embeddings AI request with the specified configuration, payload, and user ID. + * + * @param appConfig the application configuration + * @param payload the request payload + * @param userId the user ID + * @param the type of the request payload + * @param the type of the AIRequest + * @return a new AIRequest instance + */ + public static > R quickEmbeddings(final AppConfig appConfig, + final T payload, + final String userId) { + return quick(AIModelType.EMBEDDINGS, appConfig, payload, userId); + } + + public static > Builder builder() { + return new Builder<>(); + } + + /** + * Resolves the URL for the specified model type and application configuration. + * + * @param type the AI model type + * @param appConfig the application configuration + * @return the resolved URL + */ + static String resolveUrl(final AIModelType type, final AppConfig appConfig) { + switch (type) { + case TEXT: + return appConfig.getApiUrl(); + case IMAGE: + return appConfig.getApiImageUrl(); + case EMBEDDINGS: + return appConfig.getApiEmbeddingsUrl(); + default: + throw new IllegalArgumentException("Invalid AIModelType: " + type); + } + } + + @SuppressWarnings("unchecked") + private static , R extends AIRequest> R quick( + final String url, + final AppConfig appConfig, + final AIModelType type, + final T payload, + final String usderId) { + return (R) AIRequest.builder() + .withUrl(url) + .withConfig(appConfig) + .withType(type) + .withPayload(payload) + .withUserId(usderId) + .build(); + } + + private static > R quick( + final AIModelType type, + final AppConfig appConfig, + final T payload, + final String userId) { + return quick(resolveUrl(type, appConfig), appConfig, type, payload, userId); + } + + public String getUrl() { + return url; + } + + public String getMethod() { + return method; + } + + public AppConfig getConfig() { + return config; + } + + public AIModelType getType() { + return type; + } + + public T getPayload() { + return payload; + } + + public String getUserId() { + return userId; + } + + @Override + public String toString() { + return "AIRequest{" + + "url='" + url + '\'' + + ", method='" + method + '\'' + + ", config=" + config + + ", type=" + type + + ", payload=" + payloadToString() + + ", userId='" + userId + '\'' + + '}'; + } + + public String payloadToString() { + return payload.toString(); + } + + public static class Builder> { + + String url; + String method = HttpMethod.POST; + AppConfig config; + AIModelType type; + T payload; + String userId; + + @SuppressWarnings("unchecked") + B self() { + return (B) this; + } + + public B withUrl(final String url) { + this.url = url; + return self(); + } + + public B withConfig(final AppConfig config) { + this.config = config; + return self(); + } + + public B withType(final AIModelType type) { + this.type = type; + return self(); + } + + public B withPayload(final T payload) { + this.payload = payload; + return self(); + } + + public B withUserId(final String userId) { + this.userId = userId; + return self(); + } + + public AIRequest build() { + return new AIRequest<>(this); + } + + } +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponse.java b/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponse.java new file mode 100644 index 000000000000..8d9887b24571 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponse.java @@ -0,0 +1,50 @@ +package com.dotcms.ai.domain; + +/** + * Represents a response from an AI service. + * + *

+ * This class encapsulates the details of an AI response, including the response content. + * It provides methods to build and retrieve the response. + *

+ * + *

+ * The class also provides a static instance representing an empty response. + *

+ * + * @author vico + */ +public class AIResponse { + + public static final AIResponse EMPTY = builder().build(); + + private final String response; + + private AIResponse(final Builder builder) { + this.response = builder.response; + } + + public static Builder builder() { + return new Builder(); + } + + public String getResponse() { + return response; + } + + public static class Builder { + + private String response; + + public Builder withResponse(final String response) { + this.response = response; + return this; + } + + + public AIResponse build() { + return new AIResponse(this); + } + + } +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponseData.java b/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponseData.java new file mode 100644 index 000000000000..85ac2d9d0483 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/domain/AIResponseData.java @@ -0,0 +1,68 @@ +package com.dotcms.ai.domain; + +import com.dotmarketing.exception.DotRuntimeException; +import org.apache.commons.lang3.StringUtils; + +/** + * Represents the data of a response from an AI service. + * + *

+ * This class encapsulates the details of an AI response, including the response content, error message, + * status, and any exceptions that may have occurred. It provides methods to retrieve and set these details, + * as well as a method to check if the response was successful. + *

+ * + * @author vico + */ +public class AIResponseData { + + private String response; + private String error; + private ModelStatus status; + private DotRuntimeException exception; + + public String getResponse() { + return response; + } + + public void setResponse(String response) { + this.response = response; + } + + public String getError() { + return error; + } + + public void setError(final String error) { + this.error = error; + } + + public ModelStatus getStatus() { + return status; + } + + public void setStatus(ModelStatus status) { + this.status = status; + } + + public DotRuntimeException getException() { + return exception; + } + + public void setException(DotRuntimeException exception) { + this.exception = exception; + } + + public boolean isSuccess() { + return StringUtils.isBlank(error); + } + + @Override + public String toString() { + return "AIResponseData{" + + "response='" + response + '\'' + + ", error='" + error + '\'' + + ", status=" + status + + '}'; + } +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/JSONObjectAIRequest.java b/dotCMS/src/main/java/com/dotcms/ai/domain/JSONObjectAIRequest.java new file mode 100644 index 000000000000..f111e3b10022 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/domain/JSONObjectAIRequest.java @@ -0,0 +1,106 @@ +package com.dotcms.ai.domain; + +import com.dotcms.ai.app.AIModelType; +import com.dotcms.ai.app.AppConfig; +import com.dotmarketing.util.json.JSONObject; + +/** + * Represents a request to an AI service with a JSON payload. + * + *

+ * This class encapsulates the details of an AI request with a JSON payload, including the URL, HTTP method, + * configuration, model type, payload, and user ID. It provides methods to create and configure AI requests + * for different model types such as text, image, and embeddings. + *

+ * + * @author vico + */ +public class JSONObjectAIRequest extends AIRequest { + + JSONObjectAIRequest(final Builder builder) { + super(builder); + } + + /** + * Creates a quick text AI request with the specified configuration, payload, and user ID. + * + * @param appConfig the application configuration + * @param payload the request payload + * @param userId the user ID + * @return a new JSONObjectAIRequest instance + */ + public static JSONObjectAIRequest quickText(final AppConfig appConfig, + final JSONObject payload, + final String userId) { + + return quick(AIModelType.TEXT, appConfig, payload, userId); + } + + /** + * Creates a quick image AI request with the specified configuration, payload, and user ID. + * + * @param appConfig the application configuration + * @param payload the request payload + * @param userId the user ID + * @return a new JSONObjectAIRequest instance + */ + public static JSONObjectAIRequest quickImage(final AppConfig appConfig, + final JSONObject payload, + final String userId) { + return quick(AIModelType.IMAGE, appConfig, payload, userId); + } + + /** + * Creates a quick embeddings AI request with the specified configuration, payload, and user ID. + * + * @param appConfig the application configuration + * @param payload the request payload + * @param userId the user ID + * @return a new JSONObjectAIRequest instance + */ + public static JSONObjectAIRequest quickEmbeddings(final AppConfig appConfig, + final JSONObject payload, + final String userId) { + return quick(AIModelType.EMBEDDINGS, appConfig, payload, userId); + } + + private static JSONObjectAIRequest quick(final String url, + final AppConfig appConfig, + final AIModelType type, + final JSONObject payload, + final String userId) { + return JSONObjectAIRequest.builder() + .withUrl(url) + .withConfig(appConfig) + .withType(type) + .withPayload(payload) + .withUserId(userId) + .build(); + } + + private static JSONObjectAIRequest quick(final AIModelType type, + final AppConfig appConfig, + final JSONObject payload, + final String userId) { + return quick(resolveUrl(type, appConfig), appConfig, type, payload, userId); + } + + @Override + public String payloadToString() { + return getPayload().toString(2); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends AIRequest.Builder { + + @Override + public JSONObjectAIRequest build() { + return new JSONObjectAIRequest(this); + } + + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/Model.java b/dotCMS/src/main/java/com/dotcms/ai/domain/Model.java new file mode 100644 index 000000000000..7b2b9ca150ba --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/domain/Model.java @@ -0,0 +1,104 @@ +package com.dotcms.ai.domain; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Represents an AI model with a name, status, and index. + * + *

+ * This class encapsulates the details of an AI model, including its name, status, and index. + * It provides methods to retrieve and set these details, as well as methods to check if the model is operational. + *

+ * + *

+ * The class also provides a builder for constructing instances of the model. + *

+ * + * @author vico + */ +public class Model { + + private final String name; + private final AtomicReference status; + private final AtomicInteger index; + + private Model(final Builder builder) { + name = builder.name; + status = new AtomicReference<>(null); + index = new AtomicInteger(builder.index); + } + + public static Builder builder() { + return new Builder(); + } + + public String getName() { + return name; + } + + public ModelStatus getStatus() { + return status.get(); + } + + public void setStatus(final ModelStatus status) { + this.status.set(status); + } + + public int getIndex() { + return index.get(); + } + + public void setIndex(final int index) { + this.index.set(index); + } + + public boolean isOperational() { + return ModelStatus.ACTIVE == status.get(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Model model = (Model) o; + return Objects.equals(name, model.name); + } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } + + @Override + public String toString() { + return "Model{" + + "name='" + name + '\'' + + ", status=" + status + + ", index=" + index.get() + + '}'; + } + + public static class Builder { + + private String name; + private int index; + + public Builder withName(final String name) { + this.name = name.toLowerCase().trim(); + return this; + } + + public Builder withIndex(final int index) { + this.index = index; + return this; + } + + public Model build() { + return new Model(this); + } + + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/domain/ModelStatus.java b/dotCMS/src/main/java/com/dotcms/ai/domain/ModelStatus.java new file mode 100644 index 000000000000..15aa6bd9b69c --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/domain/ModelStatus.java @@ -0,0 +1,30 @@ +package com.dotcms.ai.domain; + +/** + * Represents the status of an AI model. + * + *

+ * This enum defines various statuses that an AI model can have, such as active, invalid, decommissioned, or unknown. + * Each status may have different implications for the operation of the model. + *

+ * + * @author vico + */ +public enum ModelStatus { + + ACTIVE(false), + INVALID(false), + DECOMMISSIONED(false), + UNKNOWN(true); + + private final boolean needsToThrow; + + ModelStatus(final boolean needsToThrow) { + this.needsToThrow = needsToThrow; + } + + public boolean doesNeedToThrow() { + return needsToThrow; + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIAllModelsExhaustedException.java b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIAllModelsExhaustedException.java new file mode 100644 index 000000000000..6833fdabb252 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIAllModelsExhaustedException.java @@ -0,0 +1,22 @@ +package com.dotcms.ai.exception; + +import com.dotmarketing.exception.DotRuntimeException; + +/** + * Exception thrown when all AI models have been exhausted. + * + *

+ * This exception is used to indicate that all available AI models have been exhausted and no further models + * are available for processing. It extends the {@link DotRuntimeException} to provide additional context + * specific to AI model exhaustion scenarios. + *

+ * + * @author vico + */ +public class DotAIAllModelsExhaustedException extends DotRuntimeException { + + public DotAIAllModelsExhaustedException(final String message) { + super(message); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIAppConfigDisabledException.java b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIAppConfigDisabledException.java new file mode 100644 index 000000000000..5b549c74fed5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIAppConfigDisabledException.java @@ -0,0 +1,22 @@ +package com.dotcms.ai.exception; + +import com.dotmarketing.exception.DotRuntimeException; + +/** + * Exception thrown when the AI application configuration is disabled. + * + *

+ * This exception is used to indicate that the AI application configuration is disabled and cannot be used. + * It extends the {@link DotRuntimeException} to provide additional context specific to AI application configuration + * disabled scenarios. + *

+ * + * @author vico + */ +public class DotAIAppConfigDisabledException extends DotRuntimeException { + + public DotAIAppConfigDisabledException(final String message) { + super(message); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIClientConnectException.java b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIClientConnectException.java new file mode 100644 index 000000000000..e17bf9b8d09f --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIClientConnectException.java @@ -0,0 +1,21 @@ +package com.dotcms.ai.exception; + +import com.dotmarketing.exception.DotRuntimeException; + +/** + * Exception thrown when there is a connection error with the AI client. + * + *

+ * This exception is used to indicate that there is a connection error with the AI client. It extends the {@link DotRuntimeException} + * to provide additional context specific to AI client connection error scenarios. + *

+ * + * @author vico + */ +public class DotAIClientConnectException extends DotRuntimeException { + + public DotAIClientConnectException(final String message, final Throwable cause) { + super(message, cause); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIModelNotFoundException.java b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIModelNotFoundException.java new file mode 100644 index 000000000000..3bd70811b123 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIModelNotFoundException.java @@ -0,0 +1,21 @@ +package com.dotcms.ai.exception; + +import com.dotmarketing.exception.DotRuntimeException; + +/** + * Exception thrown when an AI model is not found. + * + *

+ * This exception is used to indicate that a specific AI model could not be found. It extends the {@link DotRuntimeException} + * to provide additional context specific to AI model not found scenarios. + *

+ * + * @author vico + */ +public class DotAIModelNotFoundException extends DotRuntimeException { + + public DotAIModelNotFoundException(final String message) { + super(message); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIModelNotOperationalException.java b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIModelNotOperationalException.java new file mode 100644 index 000000000000..ddea5f6866f8 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/exception/DotAIModelNotOperationalException.java @@ -0,0 +1,21 @@ +package com.dotcms.ai.exception; + +import com.dotmarketing.exception.DotRuntimeException; + +/** + * Exception thrown when there is a connection error with the AI client. + * + *

+ * This exception is used to indicate that there is a connection error with the AI client. It extends the {@link DotRuntimeException} + * to provide additional context specific to AI client connection error scenarios. + *

+ * + * @author vico + */ +public class DotAIModelNotOperationalException extends DotRuntimeException { + + public DotAIModelNotOperationalException(final String message) { + super(message); + } + +} diff --git a/dotCMS/src/main/java/com/dotcms/ai/validator/AIAppValidator.java b/dotCMS/src/main/java/com/dotcms/ai/validator/AIAppValidator.java new file mode 100644 index 000000000000..f2036cba8955 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/ai/validator/AIAppValidator.java @@ -0,0 +1,126 @@ +package com.dotcms.ai.validator; + +import com.dotcms.ai.app.AIModel; +import com.dotcms.ai.app.AppConfig; +import com.dotcms.api.system.event.message.MessageSeverity; +import com.dotcms.api.system.event.message.SystemMessageEventUtil; +import com.dotcms.api.system.event.message.builder.SystemMessage; +import com.dotcms.api.system.event.message.builder.SystemMessageBuilder; +import com.dotmarketing.util.DateUtil; +import com.google.common.annotations.VisibleForTesting; +import com.liferay.portal.language.LanguageUtil; +import io.vavr.Lazy; +import io.vavr.control.Try; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +/** + * The AIAppValidator class is responsible for validating AI configurations and model usage. + * It ensures that the AI models specified in the application configuration are supported + * and not exhausted. + * + * @author vico + */ +public class AIAppValidator { + + private static final Lazy INSTANCE = Lazy.of(AIAppValidator::new); + + private SystemMessageEventUtil systemMessageEventUtil; + + private AIAppValidator() { + setSystemMessageEventUtil(SystemMessageEventUtil.getInstance()); + } + + public static AIAppValidator get() { + return INSTANCE.get(); + } + + /** + * Validates the AI configuration for the specified user. + * If the user ID is null, the validation is skipped. + * Checks if the models specified in the application configuration are supported. + * If any unsupported models are found, a warning message is pushed to the user. + * + * @param appConfig the application configuration + * @param userId the user ID + */ + public void validateAIConfig(final AppConfig appConfig, final String userId) { + if (Objects.isNull(userId)) { + AppConfig.debugLogger(getClass(), () -> "User Id is null, skipping AI configuration validation"); + return; + } + + // TODO: pr-split -> uncomment this lines + /*final Set supportedModels = AIModels.get().getOrPullSupportedModels(appConfig.getApiKey()); + final Set unsupportedModels = Stream.of( + appConfig.getModel(), + appConfig.getImageModel(), + appConfig.getEmbeddingsModel()) + .flatMap(aiModel -> aiModel.getModels().stream()) + .map(Model::getName) + .filter(model -> !supportedModels.contains(model)) + .collect(Collectors.toSet());*/ + final Set supportedModels = Set.of(); + final Set unsupportedModels = Set.of(); + if (unsupportedModels.isEmpty()) { + return; + } + + final String unsupported = String.join(", ", unsupportedModels); + final String message = Try + .of(() -> LanguageUtil.get("ai.unsupported.models", unsupported)) + .getOrElse(String.format("The following models are not supported: [%s]", unsupported)); + final SystemMessage systemMessage = new SystemMessageBuilder() + .setMessage(message) + .setSeverity(MessageSeverity.WARNING) + .setLife(DateUtil.SEVEN_SECOND_MILLIS) + .create(); + + systemMessageEventUtil.pushMessage(systemMessage, Collections.singletonList(userId)); + } + + /** + * Validates the usage of AI models for the specified user. + * If the user ID is null, the validation is skipped. + * Checks if the models specified in the AI model are exhausted or invalid. + * If any exhausted or invalid models are found, a warning message is pushed to the user. + * + * @param aiModel the AI model + * @param userId the user ID + */ + public void validateModelsUsage(final AIModel aiModel, final String userId) { + if (Objects.isNull(userId)) { + AppConfig.debugLogger(getClass(), () -> "User Id is null, skipping AI models usage validation"); + return; + } + + // TODO: pr-split -> uncomment this line + /*final String unavailableModels = aiModel.getModels() + .stream() + .map(Model::getName) + .collect(Collectors.joining(", "));*/ + final String unavailableModels = ""; + final String message = Try + .of(() -> LanguageUtil.get("ai.models.exhausted", aiModel.getType(), unavailableModels)). + getOrElse( + String.format( + "All the %s models: [%s] have been exhausted since they are invalid or has been decommissioned", + aiModel.getType(), + unavailableModels)); + final SystemMessage systemMessage = new SystemMessageBuilder() + .setMessage(message) + .setSeverity(MessageSeverity.WARNING) + .setLife(DateUtil.SEVEN_SECOND_MILLIS) + .create(); + + systemMessageEventUtil.pushMessage(systemMessage, Collections.singletonList(userId)); + } + + @VisibleForTesting + void setSystemMessageEventUtil(SystemMessageEventUtil systemMessageEventUtil) { + this.systemMessageEventUtil = systemMessageEventUtil; + } + +}