From 86c088c85c9700e4ac0ede8357bc45802642cdc6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 27 Jun 2024 14:36:34 +0200 Subject: [PATCH] feat: support custom applications (#350)(#305) --- README.md | 1 + .../java/com/epam/aidial/core/AiDial.java | 6 +- src/main/java/com/epam/aidial/core/Proxy.java | 2 + .../epam/aidial/core/config/Application.java | 2 + .../com/epam/aidial/core/config/Features.java | 2 + .../AccessControlBaseController.java | 17 +- .../controller/ApplicationController.java | 98 ++- .../core/controller/ApplicationUtil.java | 28 + .../core/controller/ControllerSelector.java | 12 +- .../core/controller/DeleteFileController.java | 2 +- .../controller/DeploymentPostController.java | 88 ++- .../controller/DownloadFileController.java | 2 +- .../controller/FileMetadataController.java | 2 +- .../core/controller/ResourceController.java | 46 +- .../core/controller/ShareController.java | 11 - .../core/controller/UploadFileController.java | 2 +- .../epam/aidial/core/data/ResourceType.java | 4 +- .../epam/aidial/core/limiter/RateLimiter.java | 1 - .../service/CustomApplicationService.java | 177 +++++ .../core/service/PublicationService.java | 55 +- .../aidial/core/service/PublicationUtil.java | 9 +- .../service/ResourceOperationService.java | 4 +- .../aidial/core/service/ResourceService.java | 2 +- .../com/epam/aidial/core/util/ProxyUtil.java | 25 +- .../epam/aidial/core/util/ResourceUtil.java | 2 +- src/main/resources/aidial.settings.json | 3 + .../aidial/core/CustomApplicationApiTest.java | 693 ++++++++++++++++++ .../controller/ControllerSelectorTest.java | 25 + .../DeploymentPostControllerTest.java | 39 +- .../aidial/core/limiter/RateLimiterTest.java | 1 - .../core/service/PublicationUtilTest.java | 37 +- src/test/resources/aidial.settings.json | 3 + 32 files changed, 1273 insertions(+), 128 deletions(-) create mode 100644 src/main/java/com/epam/aidial/core/controller/ApplicationUtil.java create mode 100644 src/main/java/com/epam/aidial/core/service/CustomApplicationService.java create mode 100644 src/test/java/com/epam/aidial/core/CustomApplicationApiTest.java diff --git a/README.md b/README.md index 9962d5b2e..f91a4b7bb 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Priority order: | redis.provider.serverless | - | Yes | The flag indicates if the cache is serverless. **Note**. It's applied to `aws-elasti-cache` | invitations.ttlInSeconds | 259200 | No |Invitation time to live in seconds. | access.admin.rules | - | No |Matches claims from identity providers with the rules to figure out whether a user is allowed to perform admin actions, like deleting any resource or approving a publication. Example: [{"source": "roles", "function": "EQUAL", "targets": ["admin"]}]. If roles contain "admin, the actions are allowed. +| applications.includeCustomApps | false | No |The flag indicates whether custom applications should be included into openai listing ### Storage requirements diff --git a/src/main/java/com/epam/aidial/core/AiDial.java b/src/main/java/com/epam/aidial/core/AiDial.java index 13c8a8fe5..38069f5ff 100644 --- a/src/main/java/com/epam/aidial/core/AiDial.java +++ b/src/main/java/com/epam/aidial/core/AiDial.java @@ -12,6 +12,7 @@ import com.epam.aidial.core.security.AccessTokenValidator; import com.epam.aidial.core.security.ApiKeyStore; import com.epam.aidial.core.security.EncryptionService; +import com.epam.aidial.core.service.CustomApplicationService; import com.epam.aidial.core.service.InvitationService; import com.epam.aidial.core.service.LockService; import com.epam.aidial.core.service.NotificationService; @@ -124,11 +125,14 @@ void start() throws Exception { ApiKeyStore apiKeyStore = new ApiKeyStore(resourceService, vertx); ConfigStore configStore = new FileConfigStore(vertx, settings("config"), apiKeyStore); + CustomApplicationService customApplicationService = new CustomApplicationService(encryptionService, + resourceService, shareService, accessService, settings("applications")); + proxy = new Proxy(vertx, client, configStore, logStore, rateLimiter, upstreamBalancer, accessTokenValidator, storage, encryptionService, apiKeyStore, tokenStatsTracker, resourceService, invitationService, shareService, publicationService, accessService, lockService, resourceOperationService, ruleService, - notificationService, version()); + notificationService, customApplicationService, version()); server = vertx.createHttpServer(new HttpServerOptions(settings("server"))).requestHandler(proxy); open(server, HttpServer::listen); diff --git a/src/main/java/com/epam/aidial/core/Proxy.java b/src/main/java/com/epam/aidial/core/Proxy.java index da0eeb85a..653bd7e27 100644 --- a/src/main/java/com/epam/aidial/core/Proxy.java +++ b/src/main/java/com/epam/aidial/core/Proxy.java @@ -12,6 +12,7 @@ import com.epam.aidial.core.security.ApiKeyStore; import com.epam.aidial.core.security.EncryptionService; import com.epam.aidial.core.security.ExtractedClaims; +import com.epam.aidial.core.service.CustomApplicationService; import com.epam.aidial.core.service.InvitationService; import com.epam.aidial.core.service.LockService; import com.epam.aidial.core.service.NotificationService; @@ -87,6 +88,7 @@ public class Proxy implements Handler { private final ResourceOperationService resourceOperationService; private final RuleService ruleService; private final NotificationService notificationService; + private final CustomApplicationService customApplicationService; private final String version; @Override diff --git a/src/main/java/com/epam/aidial/core/config/Application.java b/src/main/java/com/epam/aidial/core/config/Application.java index f0f2c7fc6..56853a3ac 100644 --- a/src/main/java/com/epam/aidial/core/config/Application.java +++ b/src/main/java/com/epam/aidial/core/config/Application.java @@ -1,5 +1,6 @@ package com.epam.aidial.core.config; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; @@ -7,5 +8,6 @@ @Data @Accessors(chain = true) @EqualsAndHashCode(callSuper = true) +@JsonInclude(JsonInclude.Include.NON_NULL) public class Application extends Deployment { } \ No newline at end of file diff --git a/src/main/java/com/epam/aidial/core/config/Features.java b/src/main/java/com/epam/aidial/core/config/Features.java index a91477683..7f8b15d96 100644 --- a/src/main/java/com/epam/aidial/core/config/Features.java +++ b/src/main/java/com/epam/aidial/core/config/Features.java @@ -1,8 +1,10 @@ package com.epam.aidial.core.config; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; @Data +@JsonInclude(JsonInclude.Include.NON_NULL) public class Features { private String rateEndpoint; private String tokenizeEndpoint; diff --git a/src/main/java/com/epam/aidial/core/controller/AccessControlBaseController.java b/src/main/java/com/epam/aidial/core/controller/AccessControlBaseController.java index a1cebd59a..31fea2984 100644 --- a/src/main/java/com/epam/aidial/core/controller/AccessControlBaseController.java +++ b/src/main/java/com/epam/aidial/core/controller/AccessControlBaseController.java @@ -7,6 +7,7 @@ import com.epam.aidial.core.util.HttpStatus; import io.vertx.core.Future; import lombok.AllArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; @AllArgsConstructor public abstract class AccessControlBaseController { @@ -29,11 +30,19 @@ public Future handle(String resourceUrl) { return proxy.getVertx() .executeBlocking(() -> { AccessService service = proxy.getAccessService(); - return isWriteAccess ? service.hasWriteAccess(resource, context) : service.hasReadAccess(resource, context); + boolean hasWriteAccess = service.hasWriteAccess(resource, context); + if (hasWriteAccess) { + // pair of writeAccess, readAccess + return Pair.of(true, true); + } else { + // pair of writeAccess, readAccess + return Pair.of(false, service.hasReadAccess(resource, context)); + } }, false) - .map(hasAccess -> { + .map(pair -> { + boolean hasAccess = isWriteAccess ? pair.getLeft() : pair.getRight(); if (hasAccess) { - handle(resource); + handle(resource, pair.getLeft()); } else { context.respond(HttpStatus.FORBIDDEN, "You don't have an access to: " + resourceUrl); } @@ -41,6 +50,6 @@ public Future handle(String resourceUrl) { }); } - protected abstract Future handle(ResourceDescription resource); + protected abstract Future handle(ResourceDescription resource, boolean hasWriteAccess); } diff --git a/src/main/java/com/epam/aidial/core/controller/ApplicationController.java b/src/main/java/com/epam/aidial/core/controller/ApplicationController.java index 4ecd246bc..1a2481501 100644 --- a/src/main/java/com/epam/aidial/core/controller/ApplicationController.java +++ b/src/main/java/com/epam/aidial/core/controller/ApplicationController.java @@ -1,36 +1,61 @@ package com.epam.aidial.core.controller; +import com.epam.aidial.core.Proxy; import com.epam.aidial.core.ProxyContext; import com.epam.aidial.core.config.Application; import com.epam.aidial.core.config.Config; import com.epam.aidial.core.data.ApplicationData; import com.epam.aidial.core.data.ListData; +import com.epam.aidial.core.service.CustomApplicationService; +import com.epam.aidial.core.service.PermissionDeniedException; +import com.epam.aidial.core.service.ResourceNotFoundException; import com.epam.aidial.core.util.HttpStatus; import io.vertx.core.Future; -import lombok.RequiredArgsConstructor; +import io.vertx.core.Vertx; +import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.List; -@RequiredArgsConstructor +@Slf4j public class ApplicationController { private final ProxyContext context; + private final Vertx vertx; + private final CustomApplicationService customApplicationService; + private final boolean includeCustomApplications; + + public ApplicationController(ProxyContext context, Proxy proxy) { + this.context = context; + this.vertx = proxy.getVertx(); + this.customApplicationService = proxy.getCustomApplicationService(); + this.includeCustomApplications = customApplicationService.includeCustomApplications(); + } public Future getApplication(String applicationId) { Config config = context.getConfig(); Application application = config.getApplications().get(applicationId); - if (application == null) { - return context.respond(HttpStatus.NOT_FOUND); + Future applicationFuture; + if (application != null) { + if (!DeploymentController.hasAccess(context, application)) { + return context.respond(HttpStatus.FORBIDDEN); + } + applicationFuture = Future.succeededFuture(application); + } else { + applicationFuture = vertx.executeBlocking(() -> customApplicationService.getCustomApplication(applicationId, context), false); } - if (!DeploymentController.hasAccess(context, application)) { - return context.respond(HttpStatus.FORBIDDEN); - } + applicationFuture.map(app -> { + if (app == null) { + throw new ResourceNotFoundException(applicationId); + } + + ApplicationData data = ApplicationUtil.mapApplication(app); + context.respond(HttpStatus.OK, data); + return null; + }).onFailure(error -> handleRequestError(applicationId, error)); - ApplicationData data = createApplication(application); - context.respond(HttpStatus.OK, data); return Future.succeededFuture(); } @@ -40,7 +65,7 @@ public Future getApplications() { for (Application application : config.getApplications().values()) { if (DeploymentController.hasAccess(context, application)) { - ApplicationData data = createApplication(application); + ApplicationData data = ApplicationUtil.mapApplication(application); applications.add(data); } } @@ -48,22 +73,47 @@ public Future getApplications() { ListData list = new ListData<>(); list.setData(applications); - context.respond(HttpStatus.OK, list); + if (includeCustomApplications) { + vertx.executeBlocking(() -> { + List ownCustomApplications = customApplicationService.getOwnCustomApplications(context); + for (Application application : ownCustomApplications) { + ApplicationData data = ApplicationUtil.mapApplication(application); + applications.add(data); + } + List sharedApplications = customApplicationService.getSharedApplications(context); + for (Application application : sharedApplications) { + ApplicationData data = ApplicationUtil.mapApplication(application); + applications.add(data); + } + List publicApplications = customApplicationService.getPublicApplications(context); + for (Application application : publicApplications) { + ApplicationData data = ApplicationUtil.mapApplication(application); + applications.add(data); + } + return null; + }, false) + .onSuccess(ignore -> context.respond(HttpStatus.OK, list) + .onFailure(error -> { + log.error("Can't fetch custom applications", error); + context.respond(HttpStatus.INTERNAL_SERVER_ERROR, error.getMessage()); + })); + } else { + context.respond(HttpStatus.OK, list); + } + return Future.succeededFuture(); } - private static ApplicationData createApplication(Application application) { - ApplicationData data = new ApplicationData(); - data.setId(application.getName()); - data.setApplication(application.getName()); - data.setDisplayName(application.getDisplayName()); - data.setDisplayVersion(application.getDisplayVersion()); - data.setIconUrl(application.getIconUrl()); - data.setDescription(application.getDescription()); - data.setFeatures(DeploymentController.createFeatures(application.getFeatures())); - data.setInputAttachmentTypes(application.getInputAttachmentTypes()); - data.setMaxInputAttachments(application.getMaxInputAttachments()); - data.setDefaults(application.getDefaults()); - return data; + private void handleRequestError(String applicationId, Throwable error) { + if (error instanceof PermissionDeniedException) { + log.error("Forbidden application {}. Key: {}. User sub: {}", applicationId, context.getProject(), context.getUserSub()); + context.respond(HttpStatus.FORBIDDEN, error.getMessage()); + } else if (error instanceof ResourceNotFoundException) { + log.error("Application not found {}", applicationId, error); + context.respond(HttpStatus.NOT_FOUND, error.getMessage()); + } else { + log.error("Failed to load application {}", applicationId, error); + context.respond(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to load application: " + applicationId); + } } } \ No newline at end of file diff --git a/src/main/java/com/epam/aidial/core/controller/ApplicationUtil.java b/src/main/java/com/epam/aidial/core/controller/ApplicationUtil.java new file mode 100644 index 000000000..ebba1ea60 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/controller/ApplicationUtil.java @@ -0,0 +1,28 @@ +package com.epam.aidial.core.controller; + +import com.epam.aidial.core.config.Application; +import com.epam.aidial.core.data.ApplicationData; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +@UtilityClass +@Slf4j +public class ApplicationUtil { + + public ApplicationData mapApplication(Application application) { + ApplicationData data = new ApplicationData(); + data.setId(application.getName()); + data.setApplication(application.getName()); + data.setDisplayName(application.getDisplayName()); + data.setDisplayVersion(application.getDisplayVersion()); + data.setIconUrl(application.getIconUrl()); + data.setDescription(application.getDescription()); + data.setFeatures(DeploymentController.createFeatures(application.getFeatures())); + data.setInputAttachmentTypes(application.getInputAttachmentTypes()); + data.setMaxInputAttachments(application.getMaxInputAttachments()); + data.setDefaults(application.getDefaults()); + + return data; + } + +} diff --git a/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java b/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java index edc131899..5c9cd8cbd 100644 --- a/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java +++ b/src/main/java/com/epam/aidial/core/controller/ControllerSelector.java @@ -16,7 +16,7 @@ @UtilityClass public class ControllerSelector { - private static final Pattern PATTERN_POST_DEPLOYMENT = Pattern.compile("^/+openai/deployments/([^/]+)/(completions|chat/completions|embeddings)$"); + private static final Pattern PATTERN_POST_DEPLOYMENT = Pattern.compile("^/+openai/deployments/(.+?)/(completions|chat/completions|embeddings)$"); private static final Pattern PATTERN_DEPLOYMENT = Pattern.compile("^/+openai/deployments/([^/]+)$"); private static final Pattern PATTERN_DEPLOYMENTS = Pattern.compile("^/+openai/deployments$"); @@ -29,7 +29,7 @@ public class ControllerSelector { private static final Pattern PATTERN_ASSISTANT = Pattern.compile("^/+openai/assistants/([^/]+)$"); private static final Pattern PATTERN_ASSISTANTS = Pattern.compile("^/+openai/assistants$"); - private static final Pattern PATTERN_APPLICATION = Pattern.compile("^/+openai/applications/([^/]+)$"); + private static final Pattern PATTERN_APPLICATION = Pattern.compile("^/+openai/applications/(.+?)$"); private static final Pattern PATTERN_APPLICATIONS = Pattern.compile("^/+openai/applications$"); @@ -38,8 +38,8 @@ public class ControllerSelector { private static final Pattern PATTERN_FILES = Pattern.compile("^/v1/files/[a-zA-Z0-9]+/.*"); private static final Pattern PATTERN_FILES_METADATA = Pattern.compile("^/v1/metadata/files/[a-zA-Z0-9]+/.*"); - private static final Pattern PATTERN_RESOURCE = Pattern.compile("^/v1/(conversations|prompts)/[a-zA-Z0-9]+/.*"); - private static final Pattern PATTERN_RESOURCE_METADATA = Pattern.compile("^/v1/metadata/(conversations|prompts)/[a-zA-Z0-9]+/.*"); + private static final Pattern PATTERN_RESOURCE = Pattern.compile("^/v1/(conversations|prompts|applications)/[a-zA-Z0-9]+/.*"); + private static final Pattern PATTERN_RESOURCE_METADATA = Pattern.compile("^/v1/metadata/(conversations|prompts|applications)/[a-zA-Z0-9]+/.*"); private static final Pattern PATTERN_RATE_RESPONSE = Pattern.compile("^/+v1/([^/]+)/rate$"); private static final Pattern PATTERN_TOKENIZE = Pattern.compile("^/+v1/deployments/([^/]+)/tokenize$"); @@ -135,14 +135,14 @@ private static Controller selectGet(Proxy proxy, ProxyContext context, String pa match = match(PATTERN_APPLICATION, path); if (match != null) { - ApplicationController controller = new ApplicationController(context); + ApplicationController controller = new ApplicationController(context, proxy); String application = UrlUtil.decodePath(match.group(1)); return () -> controller.getApplication(application); } match = match(PATTERN_APPLICATIONS, path); if (match != null) { - ApplicationController controller = new ApplicationController(context); + ApplicationController controller = new ApplicationController(context, proxy); return controller::getApplications; } diff --git a/src/main/java/com/epam/aidial/core/controller/DeleteFileController.java b/src/main/java/com/epam/aidial/core/controller/DeleteFileController.java index 3c6bbccd7..c19bb2c63 100644 --- a/src/main/java/com/epam/aidial/core/controller/DeleteFileController.java +++ b/src/main/java/com/epam/aidial/core/controller/DeleteFileController.java @@ -31,7 +31,7 @@ public DeleteFileController(Proxy proxy, ProxyContext context) { } @Override - protected Future handle(ResourceDescription resource) { + protected Future handle(ResourceDescription resource, boolean hasWriteAccess) { if (resource.isFolder()) { return context.respond(HttpStatus.BAD_REQUEST, "Can't delete a folder"); } diff --git a/src/main/java/com/epam/aidial/core/controller/DeploymentPostController.java b/src/main/java/com/epam/aidial/core/controller/DeploymentPostController.java index 66f8dd9e3..93d697071 100644 --- a/src/main/java/com/epam/aidial/core/controller/DeploymentPostController.java +++ b/src/main/java/com/epam/aidial/core/controller/DeploymentPostController.java @@ -16,6 +16,9 @@ import com.epam.aidial.core.function.enhancement.EnhanceAssistantRequestFn; import com.epam.aidial.core.function.enhancement.EnhanceModelRequestFn; import com.epam.aidial.core.limiter.RateLimitResult; +import com.epam.aidial.core.service.CustomApplicationService; +import com.epam.aidial.core.service.PermissionDeniedException; +import com.epam.aidial.core.service.ResourceNotFoundException; import com.epam.aidial.core.token.TokenUsage; import com.epam.aidial.core.token.TokenUsageParser; import com.epam.aidial.core.upstream.DeploymentUpstreamProvider; @@ -47,7 +50,6 @@ import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.function.Function; @Slf4j public class DeploymentPostController { @@ -58,12 +60,13 @@ public class DeploymentPostController { private final Proxy proxy; private final ProxyContext context; - + private final CustomApplicationService applicationService; private final List> enhancementFunctions; public DeploymentPostController(Proxy proxy, ProxyContext context) { this.proxy = proxy; this.context = context; + this.applicationService = proxy.getCustomApplicationService(); this.enhancementFunctions = List.of(new CollectAttachmentsFn(proxy, context), new ApplyDefaultDeploymentSettingsFn(proxy, context), new EnhanceAssistantRequestFn(proxy, context), @@ -77,37 +80,66 @@ public Future handle(String deploymentId, String deploymentApi) { } Deployment deployment = context.getConfig().selectDeployment(deploymentId); - if (!isValidDeploymentApi(deployment, deploymentApi)) { - deployment = null; - } + boolean isValidDeployment = isValidDeploymentApi(deployment, deploymentApi); - if (deployment == null) { - log.error("Deployment {} is not found", deploymentId); - return context.respond(HttpStatus.NOT_FOUND, "Deployment is not found"); + if (!isValidDeployment) { + log.warn("Deployment {}/{} is not valid", deploymentId, deploymentApi); + return respond(HttpStatus.NOT_FOUND, "Deployment is not found"); } - if (!isBaseAssistant(deployment) && !DeploymentController.hasAccess(context, deployment)) { - log.error("Forbidden deployment {}. Key: {}. User sub: {}", deploymentId, context.getProject(), context.getUserSub()); - return context.respond(HttpStatus.FORBIDDEN, "Forbidden deployment"); + Future deploymentFuture; + if (deployment != null) { + if (!isBaseAssistant(deployment) && !DeploymentController.hasAccess(context, deployment)) { + log.error("Forbidden deployment {}. Key: {}. User sub: {}", deploymentId, context.getProject(), context.getUserSub()); + return respond(HttpStatus.FORBIDDEN, "Forbidden deployment: " + deploymentId); + } + deploymentFuture = Future.succeededFuture(deployment); + } else { + deploymentFuture = proxy.getVertx().executeBlocking(() -> + applicationService.getCustomApplication(deploymentId, context), false); } - context.setDeployment(deployment); + return deploymentFuture + .map(dep -> { + if (dep == null) { + throw new ResourceNotFoundException("Deployment " + deploymentId + " not found"); + } + + context.setDeployment(dep); + return dep; + }) + .compose(dep -> { + if (dep instanceof Model) { + return proxy.getRateLimiter().limit(context); + } else { + return Future.succeededFuture(RateLimitResult.SUCCESS); + } + }) + .map(rateLimitResult -> { + if (rateLimitResult.status() == HttpStatus.OK) { + handleRateLimitSuccess(deploymentId); + } else { + handleRateLimitHit(deploymentId, rateLimitResult); + } + return null; + }) + .otherwise(error -> { + handleRequestError(deploymentId, error); + return null; + }); + } - Future rateLimitResultFuture; - if (deployment instanceof Model) { - rateLimitResultFuture = proxy.getRateLimiter().limit(context); + private void handleRequestError(String deploymentId, Throwable error) { + if (error instanceof PermissionDeniedException) { + log.error("Forbidden deployment {}. Key: {}. User sub: {}", deploymentId, context.getProject(), context.getUserSub()); + respond(HttpStatus.FORBIDDEN, error.getMessage()); + } else if (error instanceof ResourceNotFoundException) { + log.error("Deployment not found {}", deploymentId, error); + respond(HttpStatus.NOT_FOUND, error.getMessage()); } else { - rateLimitResultFuture = Future.succeededFuture(RateLimitResult.SUCCESS); + log.error("Failed to handle deployment {}", deploymentId, error); + respond(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to process deployment: " + deploymentId); } - - return rateLimitResultFuture.map((Function) result -> { - if (result.status() == HttpStatus.OK) { - handleRateLimitSuccess(deploymentId); - } else { - handleRateLimitHit(deploymentId, result); - } - return null; - }); } private void handleRateLimitSuccess(String deploymentId) { @@ -160,8 +192,8 @@ private void handleRateLimitHit(String deploymentId, RateLimitResult result) { } private void handleError(Throwable error) { - log.error("Can't handle request. Key: {}. User sub: {}. Trace: {}. Span: {}", - context.getProject(), context.getUserSub(), context.getTraceId(), context.getSpanId(), error); + log.error("Can't handle request. Key: {}. User sub: {}. Trace: {}. Span: {}. Error: {}", + context.getProject(), context.getUserSub(), context.getTraceId(), context.getSpanId(), error.getMessage()); respond(HttpStatus.INTERNAL_SERVER_ERROR); } @@ -348,7 +380,7 @@ void handleResponse() { proxy.getLogStore().save(context); log.info("Sent response to client. Trace: {}. Span: {}. Key: {}. Deployment: {}. Endpoint: {}. Upstream: {}. Status: {}. Length: {}." - + " Timing: {} (body={}, connect={}, header={}, body={}). Tokens: {}", + + " Timing: {} (body={}, connect={}, header={}, body={}). Tokens: {}", context.getTraceId(), context.getSpanId(), context.getProject(), context.getDeployment().getName(), context.getDeployment().getEndpoint(), diff --git a/src/main/java/com/epam/aidial/core/controller/DownloadFileController.java b/src/main/java/com/epam/aidial/core/controller/DownloadFileController.java index 50058d4e8..013476c25 100644 --- a/src/main/java/com/epam/aidial/core/controller/DownloadFileController.java +++ b/src/main/java/com/epam/aidial/core/controller/DownloadFileController.java @@ -24,7 +24,7 @@ public DownloadFileController(Proxy proxy, ProxyContext context) { } @Override - protected Future handle(ResourceDescription resource) { + protected Future handle(ResourceDescription resource, boolean hasWriteAccess) { if (resource.isFolder()) { return context.respond(HttpStatus.BAD_REQUEST, "Can't download a folder"); } diff --git a/src/main/java/com/epam/aidial/core/controller/FileMetadataController.java b/src/main/java/com/epam/aidial/core/controller/FileMetadataController.java index 34ecdda8d..e5bb89277 100644 --- a/src/main/java/com/epam/aidial/core/controller/FileMetadataController.java +++ b/src/main/java/com/epam/aidial/core/controller/FileMetadataController.java @@ -25,7 +25,7 @@ private String getContentType() { } @Override - protected Future handle(ResourceDescription resource) { + protected Future handle(ResourceDescription resource, boolean hasWriteAccess) { BlobStorage storage = proxy.getStorage(); boolean recursive = Boolean.parseBoolean(context.getRequest().getParam("recursive", "false")); String token = context.getRequest().getParam("token"); diff --git a/src/main/java/com/epam/aidial/core/controller/ResourceController.java b/src/main/java/com/epam/aidial/core/controller/ResourceController.java index 35918ba6b..d396d81d1 100644 --- a/src/main/java/com/epam/aidial/core/controller/ResourceController.java +++ b/src/main/java/com/epam/aidial/core/controller/ResourceController.java @@ -2,6 +2,8 @@ import com.epam.aidial.core.Proxy; import com.epam.aidial.core.ProxyContext; +import com.epam.aidial.core.config.Application; +import com.epam.aidial.core.data.ApplicationData; import com.epam.aidial.core.data.Conversation; import com.epam.aidial.core.data.MetadataBase; import com.epam.aidial.core.data.Prompt; @@ -49,9 +51,9 @@ public ResourceController(Proxy proxy, ProxyContext context, boolean metadata) { } @Override - protected Future handle(ResourceDescription descriptor) { + protected Future handle(ResourceDescription descriptor, boolean hasWriteAccess) { if (context.getRequest().method() == HttpMethod.GET) { - return metadata ? getMetadata(descriptor) : getResource(descriptor); + return metadata ? getMetadata(descriptor) : getResource(descriptor, hasWriteAccess); } if (context.getRequest().method() == HttpMethod.PUT) { @@ -103,7 +105,7 @@ private Future getMetadata(ResourceDescription descriptor) { }); } - private Future getResource(ResourceDescription descriptor) { + private Future getResource(ResourceDescription descriptor, boolean hasWriteAccess) { if (descriptor.isFolder()) { return context.respond(HttpStatus.BAD_REQUEST, "Folder not allowed: " + descriptor.getUrl()); } @@ -113,7 +115,14 @@ private Future getResource(ResourceDescription descriptor) { if (body == null) { context.respond(HttpStatus.NOT_FOUND, "Not found: " + descriptor.getUrl()); } else { - context.respond(HttpStatus.OK, body); + // if resource type is application and caller has no write access - return application data + if (descriptor.getType() == ResourceType.APPLICATION && !hasWriteAccess) { + Application application = ProxyUtil.convertToObject(body, Application.class, true); + ApplicationData applicationData = ApplicationUtil.mapApplication(application); + context.respond(HttpStatus.OK, ProxyUtil.convertToString(applicationData)); + } else { + context.respond(HttpStatus.OK, body); + } } }) .onFailure(error -> { @@ -152,14 +161,8 @@ private Future putResource(ResourceDescription descriptor) { throw new HttpException(HttpStatus.REQUEST_ENTITY_TOO_LARGE, message); } - String body = bytes.toString(StandardCharsets.UTF_8); - ResourceType resourceType = descriptor.getType(); - switch (resourceType) { - case PROMPT -> ProxyUtil.convertToObject(body, Prompt.class); - case CONVERSATION -> ProxyUtil.convertToObject(body, Conversation.class); - default -> throw new IllegalArgumentException("Unsupported resource type " + resourceType); - } + String body = validateRequestBody(descriptor, resourceType, bytes.toString(StandardCharsets.UTF_8)); return vertx.executeBlocking(() -> service.putResource(descriptor, body, overwrite), false); }) @@ -182,6 +185,27 @@ private Future putResource(ResourceDescription descriptor) { }); } + private static String validateRequestBody(ResourceDescription descriptor, ResourceType resourceType, String body) { + switch (resourceType) { + case PROMPT -> ProxyUtil.convertToObject(body, Prompt.class); + case CONVERSATION -> ProxyUtil.convertToObject(body, Conversation.class); + case APPLICATION -> { + Application application = ProxyUtil.convertToObject(body, Application.class, true); + if (application != null) { + // replace application name with it's url + application.setName(descriptor.getUrl()); + // defining user roles in custom applications are not allowed + application.setUserRoles(null); + // forward auth token is not allowed for custom applications + application.setForwardAuthToken(false); + body = ProxyUtil.convertToString(application, true); + } + } + default -> throw new IllegalArgumentException("Unsupported resource type " + resourceType); + } + return body; + } + private Future deleteResource(ResourceDescription descriptor) { if (descriptor.isFolder()) { return context.respond(HttpStatus.BAD_REQUEST, "Folder not allowed: " + descriptor.getUrl()); diff --git a/src/main/java/com/epam/aidial/core/controller/ShareController.java b/src/main/java/com/epam/aidial/core/controller/ShareController.java index dd9120f17..37567c58a 100644 --- a/src/main/java/com/epam/aidial/core/controller/ShareController.java +++ b/src/main/java/com/epam/aidial/core/controller/ShareController.java @@ -9,15 +9,12 @@ import com.epam.aidial.core.security.EncryptionService; import com.epam.aidial.core.service.InvitationService; import com.epam.aidial.core.service.LockService; -import com.epam.aidial.core.service.ResourceService; import com.epam.aidial.core.service.ShareService; -import com.epam.aidial.core.storage.BlobStorage; import com.epam.aidial.core.storage.BlobStorageUtil; import com.epam.aidial.core.storage.ResourceDescription; import com.epam.aidial.core.util.HttpException; import com.epam.aidial.core.util.HttpStatus; import com.epam.aidial.core.util.ProxyUtil; -import com.epam.aidial.core.util.ResourceUtil; import io.vertx.core.Future; import io.vertx.core.buffer.Buffer; import lombok.extern.slf4j.Slf4j; @@ -35,8 +32,6 @@ public class ShareController { private final EncryptionService encryptionService; private final LockService lockService; private final InvitationService invitationService; - private final ResourceService resourceService; - private final BlobStorage storage; public ShareController(Proxy proxy, ProxyContext context) { this.proxy = proxy; @@ -45,8 +40,6 @@ public ShareController(Proxy proxy, ProxyContext context) { this.encryptionService = proxy.getEncryptionService(); this.lockService = proxy.getLockService(); this.invitationService = proxy.getInvitationService(); - this.resourceService = proxy.getResourceService(); - this.storage = proxy.getStorage(); } public Future handle(Operation operation) { @@ -210,10 +203,6 @@ private ResourceLinkCollection getResourceLinkCollection(Buffer buffer, Operatio } } - private boolean hasResource(ResourceDescription resource) { - return ResourceUtil.hasResource(resource, resourceService, storage); - } - public enum Operation { CREATE, LIST, DISCARD, REVOKE, COPY } diff --git a/src/main/java/com/epam/aidial/core/controller/UploadFileController.java b/src/main/java/com/epam/aidial/core/controller/UploadFileController.java index 8a015530c..90d098e18 100644 --- a/src/main/java/com/epam/aidial/core/controller/UploadFileController.java +++ b/src/main/java/com/epam/aidial/core/controller/UploadFileController.java @@ -20,7 +20,7 @@ public UploadFileController(Proxy proxy, ProxyContext context) { } @Override - protected Future handle(ResourceDescription resource) { + protected Future handle(ResourceDescription resource, boolean hasWriteAccess) { if (resource.isFolder()) { return context.respond(HttpStatus.BAD_REQUEST, "File name is missing"); } diff --git a/src/main/java/com/epam/aidial/core/data/ResourceType.java b/src/main/java/com/epam/aidial/core/data/ResourceType.java index f7225f478..0978e79f2 100644 --- a/src/main/java/com/epam/aidial/core/data/ResourceType.java +++ b/src/main/java/com/epam/aidial/core/data/ResourceType.java @@ -6,7 +6,8 @@ public enum ResourceType { FILE("files"), CONVERSATION("conversations"), PROMPT("prompts"), LIMIT("limits"), SHARED_WITH_ME("shared_with_me"), SHARED_BY_ME("shared_by_me"), INVITATION("invitations"), - PUBLICATION("publications"), RULES("rules"), API_KEY_DATA("api_key_data"), NOTIFICATION("notifications"); + PUBLICATION("publications"), RULES("rules"), API_KEY_DATA("api_key_data"), NOTIFICATION("notifications"), + APPLICATION("applications"); private final String group; @@ -21,6 +22,7 @@ public static ResourceType of(String group) { case "prompts" -> PROMPT; case "invitations" -> INVITATION; case "publications" -> PUBLICATION; + case "applications" -> APPLICATION; default -> throw new IllegalArgumentException("Unsupported group: " + group); }; } diff --git a/src/main/java/com/epam/aidial/core/limiter/RateLimiter.java b/src/main/java/com/epam/aidial/core/limiter/RateLimiter.java index 10cbeb1ef..6b461636a 100644 --- a/src/main/java/com/epam/aidial/core/limiter/RateLimiter.java +++ b/src/main/java/com/epam/aidial/core/limiter/RateLimiter.java @@ -1,7 +1,6 @@ package com.epam.aidial.core.limiter; import com.epam.aidial.core.ProxyContext; -import com.epam.aidial.core.config.Deployment; import com.epam.aidial.core.config.Key; import com.epam.aidial.core.config.Limit; import com.epam.aidial.core.config.Role; diff --git a/src/main/java/com/epam/aidial/core/service/CustomApplicationService.java b/src/main/java/com/epam/aidial/core/service/CustomApplicationService.java new file mode 100644 index 000000000..7b8358419 --- /dev/null +++ b/src/main/java/com/epam/aidial/core/service/CustomApplicationService.java @@ -0,0 +1,177 @@ +package com.epam.aidial.core.service; + +import com.epam.aidial.core.ProxyContext; +import com.epam.aidial.core.config.Application; +import com.epam.aidial.core.data.ListSharedResourcesRequest; +import com.epam.aidial.core.data.MetadataBase; +import com.epam.aidial.core.data.NodeType; +import com.epam.aidial.core.data.ResourceFolderMetadata; +import com.epam.aidial.core.data.ResourceType; +import com.epam.aidial.core.data.SharedResourcesResponse; +import com.epam.aidial.core.security.AccessService; +import com.epam.aidial.core.security.EncryptionService; +import com.epam.aidial.core.storage.BlobStorageUtil; +import com.epam.aidial.core.storage.ResourceDescription; +import com.epam.aidial.core.util.ProxyUtil; +import io.vertx.core.json.JsonObject; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Slf4j +@RequiredArgsConstructor +public class CustomApplicationService { + + private static final int FETCH_SIZE = 1000; + + private final EncryptionService encryptionService; + private final ResourceService resourceService; + private final ShareService shareService; + private final AccessService accessService; + private final JsonObject applicationsConfig; + + /** + * Loads a custom application from provided url with permission check + * + * @param url - custom application url + * @return - custom application or null if invalid url provided or resource not found + */ + public Application getCustomApplication(String url, ProxyContext context) { + ResourceDescription resource; + try { + resource = ResourceDescription.fromAnyUrl(url, encryptionService); + } catch (Exception e) { + log.warn("Invalid resource url provided: {}", url); + throw new ResourceNotFoundException("Application %s not found".formatted(url)); + } + + if (resource.getType() != ResourceType.APPLICATION) { + throw new IllegalArgumentException("Unsupported deployment type: " + resource.getType()); + } + + boolean hasAccess = accessService.hasReadAccess(resource, context); + if (!hasAccess) { + throw new PermissionDeniedException("User don't have access to the deployment " + url); + } + + String applicationBody = resourceService.getResource(resource); + return ProxyUtil.convertToObject(applicationBody, Application.class, true); + } + + /** + * + * @return list of custom applications from user's bucket + */ + public List getOwnCustomApplications(ProxyContext context) { + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + + List applications = new ArrayList<>(); + ResourceDescription rootFolderResource = ResourceDescription.fromDecoded(ResourceType.APPLICATION, bucket, bucketLocation, null); + fetchApplicationsRecursively(rootFolderResource, applications); + + return applications; + } + + /** + * + * @return list of custom applications shared with user + */ + public List getSharedApplications(ProxyContext context) { + String bucketLocation = BlobStorageUtil.buildInitiatorBucket(context); + String bucket = encryptionService.encrypt(bucketLocation); + + ListSharedResourcesRequest listSharedResourcesRequest = new ListSharedResourcesRequest(); + listSharedResourcesRequest.setResourceTypes(Set.of(ResourceType.APPLICATION)); + SharedResourcesResponse sharedResourcesResponse = shareService.listSharedWithMe(bucket, bucketLocation, listSharedResourcesRequest); + Set metadata = sharedResourcesResponse.getResources(); + // metadata can be either item or folder + List applications = new ArrayList<>(); + for (MetadataBase meta : metadata) { + ResourceDescription resource = ResourceDescription.fromAnyUrl(meta.getUrl(), encryptionService); + fetchApplicationsRecursively(resource, applications); + } + + return applications; + } + + /** + * + * @return list of published custom applications + */ + public List getPublicApplications(ProxyContext context) { + List applications = new ArrayList<>(); + ResourceDescription rootFolderResource = ResourceDescription.fromDecoded(ResourceType.APPLICATION, BlobStorageUtil.PUBLIC_BUCKET, BlobStorageUtil.PUBLIC_LOCATION, null); + + String nextToken = null; + boolean fetchMore = true; + while (fetchMore) { + ResourceFolderMetadata metadataResponse = resourceService.getFolderMetadata(rootFolderResource, nextToken, FETCH_SIZE, true); + // if no metadata present - stop fetching + if (metadataResponse == null) { + return applications; + } + + nextToken = metadataResponse.getNextToken(); + fetchMore = nextToken != null; + + accessService.filterForbidden(context, rootFolderResource, metadataResponse); + fetchItems(metadataResponse.getItems(), applications); + } + + return applications; + } + + public boolean includeCustomApplications() { + return applicationsConfig.getBoolean("includeCustomApps", false); + } + + private void fetchApplicationsRecursively(ResourceDescription description, List result) { + if (!description.isFolder()) { + Application application = fetchApplication(description.getUrl()); + if (application != null) { + result.add(application); + } + return; + } + + String nextToken = null; + boolean fetchMore = true; + while (fetchMore) { + MetadataBase metadataResponse = resourceService.getMetadata(description, nextToken, FETCH_SIZE, true); + // if no metadata present - stop fetching + if (metadataResponse == null) { + return; + } + + if (metadataResponse instanceof ResourceFolderMetadata folderMetadata) { + nextToken = folderMetadata.getNextToken(); + fetchMore = nextToken != null; + List items = folderMetadata.getItems(); + fetchItems(items, result); + } else { + // if response is not a folder metadata - stop fetching + fetchMore = false; + } + } + } + + private void fetchItems(List items, List result) { + for (MetadataBase item : items) { + if (item.getNodeType() == NodeType.ITEM && item.getResourceType() == ResourceType.APPLICATION) { + Application application = fetchApplication(item.getUrl()); + if (application != null) { + result.add(application); + } + } + } + } + + private Application fetchApplication(String applicationUrl) { + String data = resourceService.getResource(ResourceDescription.fromAnyUrl(applicationUrl, encryptionService)); + return ProxyUtil.convertToObject(data, Application.class, true); + } +} diff --git a/src/main/java/com/epam/aidial/core/service/PublicationService.java b/src/main/java/com/epam/aidial/core/service/PublicationService.java index fc1b52026..6e5e1b4b2 100644 --- a/src/main/java/com/epam/aidial/core/service/PublicationService.java +++ b/src/main/java/com/epam/aidial/core/service/PublicationService.java @@ -50,7 +50,8 @@ public class PublicationService { private static final ResourceDescription PUBLIC_PUBLICATIONS = ResourceDescription.fromDecoded( ResourceType.PUBLICATION, PUBLIC_BUCKET, PUBLIC_LOCATION, PUBLICATIONS_NAME); - private static final Set ALLOWED_RESOURCES = Set.of(ResourceType.FILE, ResourceType.CONVERSATION, ResourceType.PROMPT); + private static final Set ALLOWED_RESOURCES = Set.of(ResourceType.FILE, ResourceType.CONVERSATION, + ResourceType.PROMPT, ResourceType.APPLICATION); private final EncryptionService encryption; private final ResourceService resources; @@ -529,6 +530,7 @@ private void copyReviewToTargetResources(List resources) { private void replaceSourceToReviewLinks(List resources) { List reviewConversations = new ArrayList<>(); + List reviewPublications = new ArrayList<>(); Map attachmentsMap = new HashMap<>(); for (Publication.Resource resource : resources) { String sourceUrl = resource.getSourceUrl(); @@ -537,23 +539,23 @@ private void replaceSourceToReviewLinks(List resources) { ResourceDescription from = ResourceDescription.fromPrivateUrl(sourceUrl, encryption); ResourceDescription to = ResourceDescription.fromPrivateUrl(reviewUrl, encryption); - ResourceType type = from.getType(); - if (type == ResourceType.CONVERSATION) { - reviewConversations.add(to); - } else if (type == ResourceType.FILE) { - attachmentsMap.put(from.getUrl(), to.getUrl()); - } + collectLinksForReplacement(reviewConversations, reviewPublications, attachmentsMap, from, to); } for (ResourceDescription reviewConversation : reviewConversations) { this.resources.computeResource(reviewConversation, body -> - PublicationUtil.replaceLinks(body, reviewConversation, attachmentsMap) + PublicationUtil.replaceConversationLinks(body, reviewConversation, attachmentsMap) ); } + + for (ResourceDescription reviewPublication : reviewPublications) { + this.resources.computeResource(reviewPublication, body -> PublicationUtil.replaceApplicationIdentity(body, reviewPublication)); + } } private void replaceReviewToTargetLinks(List resources) { - List reviewConversations = new ArrayList<>(); + List publicConversations = new ArrayList<>(); + List publicApplications = new ArrayList<>(); Map attachmentsMap = new HashMap<>(); for (Publication.Resource resource : resources) { String reviewUrl = resource.getReviewUrl(); @@ -562,19 +564,30 @@ private void replaceReviewToTargetLinks(List resources) { ResourceDescription from = ResourceDescription.fromPrivateUrl(reviewUrl, encryption); ResourceDescription to = ResourceDescription.fromPublicUrl(targetUrl); - ResourceType type = from.getType(); - if (type == ResourceType.CONVERSATION) { - reviewConversations.add(to); - } else if (type == ResourceType.FILE) { - attachmentsMap.put(from.getUrl(), to.getUrl()); - } + collectLinksForReplacement(publicConversations, publicApplications, attachmentsMap, from, to); } - for (ResourceDescription reviewConversation : reviewConversations) { - this.resources.computeResource(reviewConversation, body -> - PublicationUtil.replaceLinks(body, reviewConversation, attachmentsMap) + for (ResourceDescription publicConversation : publicConversations) { + this.resources.computeResource(publicConversation, body -> + PublicationUtil.replaceConversationLinks(body, publicConversation, attachmentsMap) ); } + + for (ResourceDescription publicApplication : publicApplications) { + this.resources.computeResource(publicApplication, body -> PublicationUtil.replaceApplicationIdentity(body, publicApplication)); + } + } + + private void collectLinksForReplacement(List publicConversations, List publicApplications, + Map attachmentsMap, ResourceDescription from, ResourceDescription to) { + ResourceType type = from.getType(); + if (type == ResourceType.CONVERSATION) { + publicConversations.add(to); + } else if (type == ResourceType.FILE) { + attachmentsMap.put(from.getUrl(), to.getUrl()); + } else if (type == ResourceType.APPLICATION) { + publicApplications.add(to); + } } private void deleteReviewResources(List resources) { @@ -596,7 +609,7 @@ private void deletePublicResources(List resources) { private boolean checkResource(ResourceDescription descriptor) { return switch (descriptor.getType()) { case FILE -> files.exists(descriptor.getAbsoluteFilePath()); - case PROMPT, CONVERSATION -> resources.hasResource(descriptor); + case PROMPT, CONVERSATION, APPLICATION -> resources.hasResource(descriptor); default -> throw new IllegalStateException("Unsupported type: " + descriptor.getType()); }; } @@ -604,7 +617,7 @@ private boolean checkResource(ResourceDescription descriptor) { private boolean copyResource(ResourceDescription from, ResourceDescription to) { return switch (from.getType()) { case FILE -> files.copy(from.getAbsoluteFilePath(), to.getAbsoluteFilePath()); - case PROMPT, CONVERSATION -> resources.copyResource(from, to); + case PROMPT, CONVERSATION, APPLICATION -> resources.copyResource(from, to); default -> throw new IllegalStateException("Unsupported type: " + from.getType()); }; } @@ -612,7 +625,7 @@ private boolean copyResource(ResourceDescription from, ResourceDescription to) { private void deleteResource(ResourceDescription descriptor) { switch (descriptor.getType()) { case FILE -> files.delete(descriptor.getAbsoluteFilePath()); - case PROMPT, CONVERSATION -> resources.deleteResource(descriptor); + case PROMPT, CONVERSATION, APPLICATION -> resources.deleteResource(descriptor); default -> throw new IllegalStateException("Unsupported type: " + descriptor.getType()); } } diff --git a/src/main/java/com/epam/aidial/core/service/PublicationUtil.java b/src/main/java/com/epam/aidial/core/service/PublicationUtil.java index 884260138..0520a1825 100644 --- a/src/main/java/com/epam/aidial/core/service/PublicationUtil.java +++ b/src/main/java/com/epam/aidial/core/service/PublicationUtil.java @@ -22,7 +22,7 @@ public class PublicationUtil { * @param attachmentsMapping - attachments map (sourceUrl -> targetUrl) to replace * @return conversation body after replacement */ - public String replaceLinks(String conversationBody, ResourceDescription targetResource, Map attachmentsMapping) { + public String replaceConversationLinks(String conversationBody, ResourceDescription targetResource, Map attachmentsMapping) { JsonObject conversation = replaceConversationIdentity(conversationBody, targetResource); if (attachmentsMapping.isEmpty()) { @@ -47,6 +47,13 @@ public String replaceLinks(String conversationBody, ResourceDescription targetRe return conversation.toString(); } + public String replaceApplicationIdentity(String applicationBody, ResourceDescription targetResource) { + JsonObject application = new JsonObject(applicationBody); + application.put("name", targetResource.getUrl()); + + return application.toString(); + } + private void replaceAttachments(JsonArray messages, Map attachmentsMapping) { if (messages == null || messages.isEmpty()) { return; diff --git a/src/main/java/com/epam/aidial/core/service/ResourceOperationService.java b/src/main/java/com/epam/aidial/core/service/ResourceOperationService.java index 48ab9a284..be4813677 100644 --- a/src/main/java/com/epam/aidial/core/service/ResourceOperationService.java +++ b/src/main/java/com/epam/aidial/core/service/ResourceOperationService.java @@ -37,7 +37,7 @@ public void moveResource(String bucket, String location, ResourceDescription sou } storage.copy(sourceResourcePath, destinationResourcePath); } - case CONVERSATION, PROMPT -> { + case CONVERSATION, PROMPT, APPLICATION -> { boolean copied = resourceService.copyResource(source, destination, overwriteIfExists); if (!copied) { throw new IllegalArgumentException("Can't move resource %s to %s, because destination resource already exists" @@ -61,7 +61,7 @@ private boolean hasResource(ResourceDescription resource) { private void deleteResource(ResourceDescription resource) { switch (resource.getType()) { case FILE -> storage.delete(resource.getAbsoluteFilePath()); - case CONVERSATION, PROMPT -> resourceService.deleteResource(resource); + case CONVERSATION, PROMPT, APPLICATION -> resourceService.deleteResource(resource); default -> throw new IllegalArgumentException("Unsupported resource type " + resource.getType()); } } diff --git a/src/main/java/com/epam/aidial/core/service/ResourceService.java b/src/main/java/com/epam/aidial/core/service/ResourceService.java index 1c1fbae32..0fe81a011 100644 --- a/src/main/java/com/epam/aidial/core/service/ResourceService.java +++ b/src/main/java/com/epam/aidial/core/service/ResourceService.java @@ -118,7 +118,7 @@ public MetadataBase getMetadata(ResourceDescription descriptor, String token, in : getResourceMetadata(descriptor); } - private ResourceFolderMetadata getFolderMetadata(ResourceDescription descriptor, String token, int limit, boolean recursive) { + public ResourceFolderMetadata getFolderMetadata(ResourceDescription descriptor, String token, int limit, boolean recursive) { String blobKey = blobKey(descriptor); PageSet set = blobStore.list(blobKey, token, limit, recursive); diff --git a/src/main/java/com/epam/aidial/core/util/ProxyUtil.java b/src/main/java/com/epam/aidial/core/util/ProxyUtil.java index 63662cb0a..595b092ef 100644 --- a/src/main/java/com/epam/aidial/core/util/ProxyUtil.java +++ b/src/main/java/com/epam/aidial/core/util/ProxyUtil.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -24,7 +25,6 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; -import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Nullable; @@ -36,6 +36,11 @@ public class ProxyUtil { .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) .build(); + public static final JsonMapper SNAKE_CASE_MAPPER = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .build(); + private static final MultiMap HOP_BY_HOP_HEADERS = MultiMap.caseInsensitiveMultiMap() .add(HttpHeaders.CONNECTION, "whatever") .add(HttpHeaders.KEEP_ALIVE, "whatever") @@ -163,12 +168,12 @@ public static T convertToObject(String payload, TypeReference type) { } @Nullable - public static T convertToObject(String payload, Class clazz) { + public static T convertToObject(String payload, Class clazz, boolean snakeCase) { if (payload == null || payload.isEmpty()) { return null; } try { - return MAPPER.readValue(payload, clazz); + return snakeCase ? SNAKE_CASE_MAPPER.readValue(payload, clazz) : MAPPER.readValue(payload, clazz); } catch (JsonProcessingException e) { log.error("Failed to convert payload to the object", e); if (e instanceof MismatchedInputException mismatchedInputException && mismatchedInputException.getPath() != null && !mismatchedInputException.getPath().isEmpty()) { @@ -182,18 +187,28 @@ public static T convertToObject(String payload, Class clazz) { } @Nullable - public static String convertToString(Object data) { + public static T convertToObject(String payload, Class clazz) { + return convertToObject(payload, clazz, false); + } + + @Nullable + public static String convertToString(Object data, boolean snakeCase) { if (data == null) { return null; } try { - return MAPPER.writeValueAsString(data); + return snakeCase ? SNAKE_CASE_MAPPER.writeValueAsString(data) : MAPPER.writeValueAsString(data); } catch (JsonProcessingException e) { throw new IllegalArgumentException(e); } } + @Nullable + public static String convertToString(Object data) { + return convertToString(data, false); + } + public static Throwable processChain(T item, List> chain) { for (BaseFunction fn : chain) { Throwable error = fn.apply(item); diff --git a/src/main/java/com/epam/aidial/core/util/ResourceUtil.java b/src/main/java/com/epam/aidial/core/util/ResourceUtil.java index 0e7cf523e..bcafab331 100644 --- a/src/main/java/com/epam/aidial/core/util/ResourceUtil.java +++ b/src/main/java/com/epam/aidial/core/util/ResourceUtil.java @@ -11,7 +11,7 @@ public class ResourceUtil { public static boolean hasResource(ResourceDescription resource, ResourceService resourceService, BlobStorage storage) { return switch (resource.getType()) { case FILE -> storage.exists(resource.getAbsoluteFilePath()); - case CONVERSATION, PROMPT -> resourceService.hasResource(resource); + case CONVERSATION, PROMPT, APPLICATION -> resourceService.hasResource(resource); default -> throw new IllegalArgumentException("Unsupported resource type " + resource.getType()); }; } diff --git a/src/main/resources/aidial.settings.json b/src/main/resources/aidial.settings.json index 8a1c62c48..cd4fd1130 100644 --- a/src/main/resources/aidial.settings.json +++ b/src/main/resources/aidial.settings.json @@ -59,6 +59,9 @@ "cacheExpiration": 300000, "compressionMinSize": 256 }, + "applications": { + "includeCustomApps": false + }, "access": { "admin": { "rules": [ diff --git a/src/test/java/com/epam/aidial/core/CustomApplicationApiTest.java b/src/test/java/com/epam/aidial/core/CustomApplicationApiTest.java new file mode 100644 index 000000000..b5cdda362 --- /dev/null +++ b/src/test/java/com/epam/aidial/core/CustomApplicationApiTest.java @@ -0,0 +1,693 @@ +package com.epam.aidial.core; + +import com.epam.aidial.core.data.InvitationLink; +import com.epam.aidial.core.util.ProxyUtil; +import io.vertx.core.http.HttpMethod; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class CustomApplicationApiTest extends ResourceBaseTest { + + @Test + void testApplicationCreation() { + Response response = send(HttpMethod.PUT, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "My Custom Application", + "display_version": "1.0", + "icon_url": "http://application1/icon.svg", + "description": "My Custom Application Description" + } + """); + verifyJsonNotExact(response, 200, """ + { + "name":"my-custom-application", + "parentPath":null, + "bucket":"3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "nodeType":"ITEM", + "resourceType":"APPLICATION", + "createdAt": "@ignore", + "updatedAt":"@ignore" + } + """); + + response = send(HttpMethod.GET, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, ""); + verifyJson(response, 200, """ + { + "name":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "endpoint":"http://application1/v1/completions", + "display_name":"My Custom Application", + "display_version":"1.0", + "icon_url":"http://application1/icon.svg", + "description":"My Custom Application Description", + "forward_auth_token":false, + "defaults": {} + } + """); + + response = send(HttpMethod.GET, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, "", "Api-key", "proxyKey2"); + verify(response, 403); + } + + @Test + void testApplicationListing() { + Response response = send(HttpMethod.PUT, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "My Custom Application", + "display_version": "1.0", + "icon_url": "http://application1/icon.svg", + "description": "My Custom Application Description", + "features": { + "rate_endpoint": "http://application1/rate", + "configuration_endpoint": "http://application1/configuration" + } + } + """); + verifyJsonNotExact(response, 200, """ + { + "name":"my-custom-application", + "parentPath":null, + "bucket":"3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "nodeType":"ITEM", + "resourceType":"APPLICATION", + "createdAt": "@ignore", + "updatedAt":"@ignore" + } + """); + + response = send(HttpMethod.PUT, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/gpt/my-custom-application", null, """ + { + "endpoint": "http://application2/v1/completions", + "display_name": "My Custom Application 2", + "display_version": "1.1", + "icon_url": "http://application2/icon.svg", + "description": "My Custom Application 2 Description" + } + """); + verifyJsonNotExact(response, 200, """ + { + "name":"my-custom-application", + "parentPath": "gpt", + "bucket":"3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/gpt/my-custom-application", + "nodeType":"ITEM", + "resourceType":"APPLICATION", + "createdAt": "@ignore", + "updatedAt":"@ignore" + } + """); + + response = send(HttpMethod.GET, "/v1/metadata/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/", null, ""); + verifyJsonNotExact(response, 200, """ + { + "name":null, + "parentPath":null, + "bucket":"3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/", + "nodeType":"FOLDER", + "resourceType":"APPLICATION", + "items":[ + { + "name":"gpt", + "parentPath":null, + "bucket":"3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/gpt/", + "nodeType":"FOLDER", + "resourceType":"APPLICATION", + "items":null + }, + { + "name":"my-custom-application", + "parentPath":null, + "bucket":"3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "nodeType":"ITEM", + "resourceType":"APPLICATION", + "updatedAt":"@ignore" + }] + } + """); + } + + @Test + void testApplicationDeletion() { + Response response = send(HttpMethod.PUT, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "My Custom Application", + "display_version": "1.0", + "icon_url": "http://application1/icon.svg", + "description": "My Custom Application Description", + "features": { + "rate_endpoint": "http://application1/rate", + "configuration_endpoint": "http://application1/configuration" + } + } + """); + verifyJsonNotExact(response, 200, """ + { + "name":"my-custom-application", + "parentPath":null, + "bucket":"3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "nodeType":"ITEM", + "resourceType":"APPLICATION", + "createdAt": "@ignore", + "updatedAt":"@ignore" + } + """); + + response = send(HttpMethod.GET, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, ""); + verifyJsonNotExact(response, 200, """ + { + "name" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "endpoint": "http://application1/v1/completions", + "display_name": "My Custom Application", + "display_version": "1.0", + "icon_url": "http://application1/icon.svg", + "description": "My Custom Application Description", + "forward_auth_token": false, + "features": { + "rate_endpoint": "http://application1/rate", + "configuration_endpoint": "http://application1/configuration" + }, + "defaults": {} + } + """); + + response = send(HttpMethod.DELETE, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, ""); + verify(response, 200); + + response = send(HttpMethod.GET, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, ""); + verify(response, 404); + } + + @Test + void testApplicationSharing() { + // check no applications shared with me + Response response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["APPLICATION"], + "with": "me" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // check no applications shared by me + response = operationRequest("/v1/ops/resource/share/list", """ + { + "resourceTypes": ["APPLICATION"], + "with": "others" + } + """); + verifyJson(response, 200, """ + { + "resources": [] + } + """); + + // create application + response = send(HttpMethod.PUT, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "My Custom Application", + "display_version": "1.0", + "icon_url": "http://application1/icon.svg", + "description": "My Custom Application Description", + "features": { + "rate_endpoint": "http://application1/rate", + "configuration_endpoint": "http://application1/configuration" + } + } + """); + verifyJsonNotExact(response, 200, """ + { + "name":"my-custom-application", + "parentPath":null, + "bucket":"3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST", + "url":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "nodeType":"ITEM", + "resourceType":"APPLICATION", + "createdAt": "@ignore", + "updatedAt":"@ignore" + } + """); + + // initialize share request + response = operationRequest("/v1/ops/resource/share/create", """ + { + "invitationType": "link", + "resources": [ + { + "url": "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application" + } + ] + } + """); + verify(response, 200); + InvitationLink invitationLink = ProxyUtil.convertToObject(response.body(), InvitationLink.class); + assertNotNull(invitationLink); + + // verify invitation details + response = send(HttpMethod.GET, invitationLink.invitationLink(), null, null); + verifyNotExact(response, 200, "\"url\":\"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application\""); + + // verify user2 do not have access to the application + response = send(HttpMethod.GET, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, "", "Api-key", "proxyKey2"); + verify(response, 403); + + // accept invitation + response = send(HttpMethod.GET, invitationLink.invitationLink(), "accept=true", null, "Api-key", "proxyKey2"); + verify(response, 200); + + // verify user2 has access to the application + response = send(HttpMethod.GET, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, "", "Api-key", "proxyKey2"); + verifyJsonNotExact(response, 200, """ + { + "id" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "application" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "display_name" : "My Custom Application", + "display_version" : "1.0", + "icon_url" : "http://application1/icon.svg", + "description" : "My Custom Application Description", + "owner" : "organization-owner", + "object" : "application", + "status" : "succeeded", + "created_at" : 1672534800, + "updated_at" : 1672534800, + "features" : { + "rate" : true, + "tokenize" : false, + "truncate_prompt" : false, + "configuration" : true, + "system_prompt" : true, + "tools" : false, + "seed" : false, + "url_attachments" : false, + "folder_attachments" : false + }, + "defaults" : { } + } + """); + + // verify user1 can list both applications (from config and own) + response = send(HttpMethod.GET, "/openai/applications"); + verifyJson(response, 200, """ + { + "data":[ + { + "id":"app", + "application":"app", + "display_name":"10k", + "icon_url":"http://localhost:7001/logo10k.png", + "description":"Some description of the application for testing", + "owner":"organization-owner", + "object":"application", + "status":"succeeded", + "created_at":1672534800, + "updated_at":1672534800, + "features":{ + "rate":true, + "tokenize":false, + "truncate_prompt":false, + "configuration":true, + "system_prompt":false, + "tools":false, + "seed":false, + "url_attachments":false, + "folder_attachments":false + }, + "defaults":{} + }, + { + "id" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "application" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "display_name" : "My Custom Application", + "display_version" : "1.0", + "icon_url" : "http://application1/icon.svg", + "description" : "My Custom Application Description", + "owner" : "organization-owner", + "object" : "application", + "status" : "succeeded", + "created_at" : 1672534800, + "updated_at" : 1672534800, + "features" : { + "rate" : true, + "tokenize" : false, + "truncate_prompt" : false, + "configuration" : true, + "system_prompt" : true, + "tools" : false, + "seed" : false, + "url_attachments" : false, + "folder_attachments" : false + }, + "defaults" : { } + } + ], + "object":"list" + } + """); + + // verify user2 can list both applications (from config and shared) + response = send(HttpMethod.GET, "/openai/applications", null, null, "Api-key", "proxyKey2"); + verifyJson(response, 200, """ + { + "data":[ + { + "id":"app", + "application":"app", + "display_name":"10k", + "icon_url":"http://localhost:7001/logo10k.png", + "description":"Some description of the application for testing", + "owner":"organization-owner", + "object":"application", + "status":"succeeded", + "created_at":1672534800, + "updated_at":1672534800, + "features":{ + "rate":true, + "tokenize":false, + "truncate_prompt":false, + "configuration":true, + "system_prompt":false, + "tools":false, + "seed":false, + "url_attachments":false, + "folder_attachments":false + }, + "defaults":{} + }, + { + "id" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "application" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "display_name" : "My Custom Application", + "display_version" : "1.0", + "icon_url" : "http://application1/icon.svg", + "description" : "My Custom Application Description", + "owner" : "organization-owner", + "object" : "application", + "status" : "succeeded", + "created_at" : 1672534800, + "updated_at" : 1672534800, + "features" : { + "rate" : true, + "tokenize" : false, + "truncate_prompt" : false, + "configuration" : true, + "system_prompt" : true, + "tools" : false, + "seed" : false, + "url_attachments" : false, + "folder_attachments" : false + }, + "defaults" : { } + } + ], + "object":"list" + } + """); + } + + @Test + void testApplicationPublication() { + Response response = send(HttpMethod.PUT, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "My Custom Application", + "display_version": "1.0", + "icon_url": "http://application1/icon.svg", + "description": "My Custom Application Description", + "features": { + "rate_endpoint": "http://application1/rate", + "configuration_endpoint": "http://application1/configuration" + } + } + """); + verify(response, 200); + + response = operationRequest("/v1/ops/publication/create", """ + { + "targetFolder": "public/folder/", + "resources": [ + { + "action": "ADD", + "sourceUrl": "applications/%s/my-custom-application", + "targetUrl": "applications/public/folder/my-custom-application" + } + ], + "rules": [ + { + "source": "roles", + "function": "EQUAL", + "targets": ["user"] + } + ] + } + """.formatted(bucket)); + verify(response, 200); + + response = operationRequest("/v1/ops/publication/approve", """ + { + "url": "publications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/0123" + } + """, "authorization", "admin"); + verifyJson(response, 200, """ + { + "url" : "publications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/0123", + "targetFolder" : "public/folder/", + "status" : "APPROVED", + "createdAt" : 0, + "resources" : [ { + "action": "ADD", + "sourceUrl" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "targetUrl" : "applications/public/folder/my-custom-application", + "reviewUrl" : "applications/2CZ9i2bcBACFts8JbBu3MdTHfU5imDZBmDVomBuDCkbhEstv1KXNzCiw693js8BLmo/my-custom-application" + } ], + "resourceTypes" : [ "APPLICATION" ], + "rules" : [ { + "function" : "EQUAL", + "source" : "roles", + "targets" : [ "user" ] + } ] + } + """); + + response = send(HttpMethod.GET, "/v1/applications/public/folder/my-custom-application", + null, null, "authorization", "admin"); + verify(response, 200); + + response = send(HttpMethod.GET, "/v1/applications/public/folder/my-custom-application", + null, null, "authorization", "user"); + verify(response, 200); + + // verify listing returns both applications (from config and public) + response = send(HttpMethod.GET, "/openai/applications", null, null, "authorization", "user"); + verifyJson(response, 200, """ + { + "data":[ + { + "id":"app", + "application":"app", + "display_name":"10k", + "icon_url":"http://localhost:7001/logo10k.png", + "description":"Some description of the application for testing", + "owner":"organization-owner", + "object":"application", + "status":"succeeded", + "created_at":1672534800, + "updated_at":1672534800, + "features":{ + "rate":true, + "tokenize":false, + "truncate_prompt":false, + "configuration":true, + "system_prompt":false, + "tools":false, + "seed":false, + "url_attachments":false, + "folder_attachments":false + }, + "defaults":{} + }, + { + "id" : "applications/public/folder/my-custom-application", + "application" : "applications/public/folder/my-custom-application", + "display_name" : "My Custom Application", + "display_version" : "1.0", + "icon_url" : "http://application1/icon.svg", + "description" : "My Custom Application Description", + "owner" : "organization-owner", + "object" : "application", + "status" : "succeeded", + "created_at" : 1672534800, + "updated_at" : 1672534800, + "features" : { + "rate" : true, + "tokenize" : false, + "truncate_prompt" : false, + "configuration" : true, + "system_prompt" : true, + "tools" : false, + "seed" : false, + "url_attachments" : false, + "folder_attachments" : false + }, + "defaults" : { } + } + ], + "object":"list" + } + """); + } + + @Test + void testOpenAiApplicationListing() { + // verify listing return only application from config + Response response = send(HttpMethod.GET, "/openai/applications"); + verifyJson(response, 200, """ + { + "data":[ + { + "id":"app", + "application":"app", + "display_name":"10k", + "icon_url":"http://localhost:7001/logo10k.png", + "description":"Some description of the application for testing", + "owner":"organization-owner", + "object":"application", + "status":"succeeded", + "created_at":1672534800, + "updated_at":1672534800, + "features":{ + "rate":true, + "tokenize":false, + "truncate_prompt":false, + "configuration":true, + "system_prompt":false, + "tools":false, + "seed":false, + "url_attachments":false, + "folder_attachments":false + }, + "defaults":{} + } + ], + "object":"list" + } + """); + + // create custom application + response = send(HttpMethod.PUT, "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", null, """ + { + "endpoint": "http://application1/v1/completions", + "display_name": "My Custom Application", + "display_version": "1.0", + "icon_url": "http://application1/icon.svg", + "description": "My Custom Application Description", + "features": { + "rate_endpoint": "http://application1/rate", + "configuration_endpoint": "http://application1/configuration" + } + } + """); + verify(response, 200); + + // get custom application with openai endpoint + response = send(HttpMethod.GET, "/openai/applications/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application"); + verifyJson(response, 200, """ + { + "id":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "application":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "display_name":"My Custom Application", + "display_version":"1.0", + "icon_url":"http://application1/icon.svg", + "description":"My Custom Application Description", + "owner":"organization-owner", + "object":"application", + "status":"succeeded", + "created_at":1672534800, + "updated_at":1672534800, + "features":{ + "rate":true, + "tokenize":false, + "truncate_prompt":false, + "configuration":true, + "system_prompt":true, + "tools":false, + "seed":false, + "url_attachments":false, + "folder_attachments":false + }, + "defaults":{} + } + """); + + // verify listing returns both applications (from config and own) + response = send(HttpMethod.GET, "/openai/applications"); + verifyJson(response, 200, """ + { + "data":[ + { + "id":"app", + "application":"app", + "display_name":"10k", + "icon_url":"http://localhost:7001/logo10k.png", + "description":"Some description of the application for testing", + "owner":"organization-owner", + "object":"application", + "status":"succeeded", + "created_at":1672534800, + "updated_at":1672534800, + "features":{ + "rate":true, + "tokenize":false, + "truncate_prompt":false, + "configuration":true, + "system_prompt":false, + "tools":false, + "seed":false, + "url_attachments":false, + "folder_attachments":false + }, + "defaults":{} + }, + { + "id" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "application" : "applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "display_name" : "My Custom Application", + "display_version" : "1.0", + "icon_url" : "http://application1/icon.svg", + "description" : "My Custom Application Description", + "owner" : "organization-owner", + "object" : "application", + "status" : "succeeded", + "created_at" : 1672534800, + "updated_at" : 1672534800, + "features" : { + "rate" : true, + "tokenize" : false, + "truncate_prompt" : false, + "configuration" : true, + "system_prompt" : true, + "tools" : false, + "seed" : false, + "url_attachments" : false, + "folder_attachments" : false + }, + "defaults" : { } + } + ], + "object":"list" + } + """); + } +} diff --git a/src/test/java/com/epam/aidial/core/controller/ControllerSelectorTest.java b/src/test/java/com/epam/aidial/core/controller/ControllerSelectorTest.java index 72a646199..9f3d15b3b 100644 --- a/src/test/java/com/epam/aidial/core/controller/ControllerSelectorTest.java +++ b/src/test/java/com/epam/aidial/core/controller/ControllerSelectorTest.java @@ -2,6 +2,7 @@ import com.epam.aidial.core.Proxy; import com.epam.aidial.core.ProxyContext; +import com.epam.aidial.core.service.CustomApplicationService; import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServerRequest; import org.junit.jupiter.api.BeforeEach; @@ -19,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -153,6 +155,9 @@ public void testSelectGetAssistantsController() { public void testSelectGetApplicationController() { when(request.path()).thenReturn("/openai/applications/app1"); when(request.method()).thenReturn(HttpMethod.GET); + CustomApplicationService customApplicationServiceMock = mock(CustomApplicationService.class); + when(proxy.getCustomApplicationService()).thenReturn(customApplicationServiceMock); + when(customApplicationServiceMock.includeCustomApplications()).thenReturn(true); Controller controller = ControllerSelector.select(proxy, context); assertNotNull(controller); SerializedLambda lambda = getSerializedLambda(controller); @@ -167,6 +172,9 @@ public void testSelectGetApplicationController() { public void testSelectGetApplicationsController() { when(request.path()).thenReturn("/openai/applications"); when(request.method()).thenReturn(HttpMethod.GET); + CustomApplicationService customApplicationServiceMock = mock(CustomApplicationService.class); + when(proxy.getCustomApplicationService()).thenReturn(customApplicationServiceMock); + when(customApplicationServiceMock.includeCustomApplications()).thenReturn(false); Controller controller = ControllerSelector.select(proxy, context); assertNotNull(controller); SerializedLambda lambda = getSerializedLambda(controller); @@ -254,6 +262,23 @@ public void testSelectPostDeploymentController() { assertEquals("completions", arg3); } + @Test + public void testSelectPostDeploymentControllerWithCustomApplication() { + when(request.path()).thenReturn("/openai/deployments/applications/bucket/my-application/chat/completions"); + when(request.method()).thenReturn(HttpMethod.POST); + Controller controller = ControllerSelector.select(proxy, context); + assertNotNull(controller); + SerializedLambda lambda = getSerializedLambda(controller); + assertNotNull(lambda); + assertEquals(3, lambda.getCapturedArgCount()); + Object arg1 = lambda.getCapturedArg(0); + Object arg2 = lambda.getCapturedArg(1); + Object arg3 = lambda.getCapturedArg(2); + assertInstanceOf(DeploymentPostController.class, arg1); + assertEquals("applications/bucket/my-application", arg2); + assertEquals("chat/completions", arg3); + } + @Test public void testSelectUploadFileController() { when(request.path()).thenReturn("/v1/files/bucket/folder1/file1.txt"); diff --git a/src/test/java/com/epam/aidial/core/controller/DeploymentPostControllerTest.java b/src/test/java/com/epam/aidial/core/controller/DeploymentPostControllerTest.java index 940d2f443..9b2c4b308 100644 --- a/src/test/java/com/epam/aidial/core/controller/DeploymentPostControllerTest.java +++ b/src/test/java/com/epam/aidial/core/controller/DeploymentPostControllerTest.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.vertx.core.Future; import io.vertx.core.MultiMap; +import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpClient; import io.vertx.core.http.HttpClientRequest; @@ -36,6 +37,7 @@ import java.io.IOException; import java.util.HashMap; import java.util.Set; +import java.util.concurrent.Callable; import static com.epam.aidial.core.Proxy.HEADER_API_KEY; import static com.epam.aidial.core.Proxy.HEADER_CONTENT_TYPE_APPLICATION_JSON; @@ -80,6 +82,9 @@ public class DeploymentPostControllerTest { @Mock private TokenStatsTracker tokenStatsTracker; + @Mock + private Vertx vertx; + @InjectMocks private DeploymentPostController controller; @@ -92,7 +97,6 @@ public void testUnsupportedContentType() { controller.handle("app1", "api"); verify(context).respond(eq(UNSUPPORTED_MEDIA_TYPE), anyString()); - } @Test @@ -106,6 +110,7 @@ public void testForbiddenDeployment() { app.setUserRoles(Set.of("role1")); config.getApplications().put("app1", app); when(context.getConfig()).thenReturn(config); + when(proxy.getTokenStatsTracker()).thenReturn(tokenStatsTracker); controller.handle("app1", "chat/completions"); @@ -121,6 +126,9 @@ public void testDeploymentNotFound() { Application app = new Application(); config.getApplications().put("app1", app); when(context.getConfig()).thenReturn(config); + when(proxy.getVertx()).thenReturn(vertx); + when(proxy.getTokenStatsTracker()).thenReturn(tokenStatsTracker); + when(vertx.executeBlocking(any(Callable.class), eq(false))).thenReturn(Future.succeededFuture(null)); controller.handle("unknown-app", "chat/completions"); @@ -365,4 +373,33 @@ public void testHandleResponse_App() { verify(tokenStatsTracker).endSpan(eq(context)); } + @Test + public void testCustomApplication() { + when(context.getRequest()).thenReturn(request); + request = mock(HttpServerRequest.class, RETURNS_DEEP_STUBS); + when(context.getRequest()).thenReturn(request); + when(request.getHeader(eq(HttpHeaders.CONTENT_TYPE))).thenReturn(HEADER_CONTENT_TYPE_APPLICATION_JSON); + Config config = new Config(); + config.setApplications(new HashMap<>()); + when(context.getConfig()).thenReturn(config); + Application application = new Application(); + application.setName("applications/bucket/app1"); + when(proxy.getVertx()).thenReturn(vertx); + when(vertx.executeBlocking(any(Callable.class), eq(false))).thenReturn(Future.succeededFuture(application)); + UpstreamBalancer balancer = mock(UpstreamBalancer.class); + when(proxy.getUpstreamBalancer()).thenReturn(balancer); + UpstreamRoute endpointRoute = mock(UpstreamRoute.class); + when(balancer.balance(any(UpstreamProvider.class))).thenReturn(endpointRoute); + when(endpointRoute.hasNext()).thenReturn(true); + MultiMap headers = mock(MultiMap.class); + when(request.headers()).thenReturn(headers); + when(context.getDeployment()).thenReturn(application); + when(proxy.getTokenStatsTracker()).thenReturn(tokenStatsTracker); + when(context.getApiKeyData()).thenReturn(new ApiKeyData()); + + controller.handle("applications/bucket/app1", "chat/completions"); + + verify(tokenStatsTracker).startSpan(eq(context)); + } + } diff --git a/src/test/java/com/epam/aidial/core/limiter/RateLimiterTest.java b/src/test/java/com/epam/aidial/core/limiter/RateLimiterTest.java index 13afbe711..7fa7b5c6b 100644 --- a/src/test/java/com/epam/aidial/core/limiter/RateLimiterTest.java +++ b/src/test/java/com/epam/aidial/core/limiter/RateLimiterTest.java @@ -2,7 +2,6 @@ import com.epam.aidial.core.ProxyContext; import com.epam.aidial.core.config.ApiKeyData; -import com.epam.aidial.core.config.Application; import com.epam.aidial.core.config.Config; import com.epam.aidial.core.config.Key; import com.epam.aidial.core.config.Limit; diff --git a/src/test/java/com/epam/aidial/core/service/PublicationUtilTest.java b/src/test/java/com/epam/aidial/core/service/PublicationUtilTest.java index 2d8f81b10..c86174dc2 100644 --- a/src/test/java/com/epam/aidial/core/service/PublicationUtilTest.java +++ b/src/test/java/com/epam/aidial/core/service/PublicationUtilTest.java @@ -29,7 +29,7 @@ void testConversationIdReplacement() { "assistantModelId": "assistantId", "lastActivityDate": 4848683153 } - """, PublicationUtil.replaceLinks(ResourceBaseTest.CONVERSATION_BODY_1, targetResource1, Map.of())); + """, PublicationUtil.replaceConversationLinks(ResourceBaseTest.CONVERSATION_BODY_1, targetResource1, Map.of())); ResourceDescription targetResource2 = ResourceDescription.fromDecoded(ResourceType.CONVERSATION, "bucketName", "bucket/location/", "folder1/conversation"); verifyJson(""" @@ -45,7 +45,7 @@ void testConversationIdReplacement() { "assistantModelId": "assistantId", "lastActivityDate": 4848683153 } - """, PublicationUtil.replaceLinks(ResourceBaseTest.CONVERSATION_BODY_1, targetResource2, Map.of())); + """, PublicationUtil.replaceConversationLinks(ResourceBaseTest.CONVERSATION_BODY_1, targetResource2, Map.of())); } @Test @@ -215,7 +215,7 @@ void testAttachmentLinksReplacement() { } ] } } - """, PublicationUtil.replaceLinks(conversationBody, targetResource, Map.of( + """, PublicationUtil.replaceConversationLinks(conversationBody, targetResource, Map.of( "files/7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/b1/LICENSE", "files/public/License", "files/7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/b1/Dockerfile", "files/public/Dockerfile"))); @@ -295,12 +295,41 @@ void testAttachmentLinksReplacement() { } } } - """, PublicationUtil.replaceLinks(conversationBody, targetResource, Map.of( + """, PublicationUtil.replaceConversationLinks(conversationBody, targetResource, Map.of( "files/7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/b1/LICENSE", "files/public/License", "files/7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/b1/Dockerfile", "files/public/Dockerfile", "files/7G9WZNcoY26Vy9D7bEgbv6zqbJGfyDp9KZyEbJR4XMZt/b1/", "files/public/attachments/"))); } + @Test + void testReplaceApplicationIdentity() { + String application = """ + { + "name":"applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/my-custom-application", + "endpoint":"http://application1/v1/completions", + "display_name":"My Custom Application", + "display_version":"1.0", + "icon_url":"http://application1/icon.svg", + "description":"My Custom Application Description", + "forward_auth_token":false, + "defaults": {} + } + """; + ResourceDescription targetResource1 = ResourceDescription.fromDecoded(ResourceType.APPLICATION, "bucketName", "bucket/location/", "my-app"); + verifyJson(""" + { + "name":"applications/bucketName/my-app", + "endpoint":"http://application1/v1/completions", + "display_name":"My Custom Application", + "display_version":"1.0", + "icon_url":"http://application1/icon.svg", + "description":"My Custom Application Description", + "forward_auth_token":false, + "defaults": {} + } + """, PublicationUtil.replaceApplicationIdentity(application, targetResource1)); + } + private static void verifyJson(String expected, String actual) { try { assertEquals(ProxyUtil.MAPPER.readTree(expected).toPrettyString(), ProxyUtil.MAPPER.readTree(actual).toPrettyString()); diff --git a/src/test/resources/aidial.settings.json b/src/test/resources/aidial.settings.json index 63036acf2..de8c799d2 100644 --- a/src/test/resources/aidial.settings.json +++ b/src/test/resources/aidial.settings.json @@ -58,6 +58,9 @@ "cacheExpiration": 300000, "compressionMinSize": 256 }, + "applications": { + "includeCustomApps": true + }, "access": { "admin": { "rules": [{"source": "roles", "function": "EQUAL", "targets": ["admin"]}]