From d5356058af05099cfa0e4d419eecfdccf05a0893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CKhshanovskyi=E2=80=9D?= Date: Mon, 14 Sep 2026 17:22:27 +0300 Subject: [PATCH] fix: accept interfaces as the only routing target in the application write API #1965 --- .../server/service/ApplicationService.java | 6 ++- .../server/util/DeploymentEndpointUtil.java | 16 ++++++ .../server/ApplicationInterfacesApiTest.java | 45 ++++++++++++++++ .../util/DeploymentEndpointUtilTest.java | 54 +++++++++++++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java b/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java index ec69d2df2..92db551da 100644 --- a/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java +++ b/server/src/main/java/com/epam/aidial/core/server/service/ApplicationService.java @@ -14,6 +14,7 @@ import com.epam.aidial.core.server.security.EncryptionService; import com.epam.aidial.core.server.util.BucketBuilder; import com.epam.aidial.core.server.util.CatalogPropertiesLinkRewriter; +import com.epam.aidial.core.server.util.DeploymentEndpointUtil; import com.epam.aidial.core.server.util.ProxyUtil; import com.epam.aidial.core.server.util.ResourceDescriptorFactory; import com.epam.aidial.core.server.validation.ApplicationTypeSchemaValidationException; @@ -609,8 +610,9 @@ private void prepareApplication(ResourceDescriptor resource, Application applica throw new IllegalArgumentException("Application schema is not found by schema id: " + applicationSchemaId); } } else if (application.getEndpoint() == null && application.getFunction() == null - && (application.getMcp() == null || application.getMcp().getEndpoint() == null)) { - throw new IllegalArgumentException("At least application endpoint, MCP endpoint or function must be provided"); + && (application.getMcp() == null || application.getMcp().getEndpoint() == null) + && !DeploymentEndpointUtil.hasRoutingInterface(application)) { + throw new IllegalArgumentException("At least application endpoint, MCP endpoint, function or interface must be provided"); } validateCatalogProperties(application); diff --git a/server/src/main/java/com/epam/aidial/core/server/util/DeploymentEndpointUtil.java b/server/src/main/java/com/epam/aidial/core/server/util/DeploymentEndpointUtil.java index c52f1fd8f..7547217ee 100644 --- a/server/src/main/java/com/epam/aidial/core/server/util/DeploymentEndpointUtil.java +++ b/server/src/main/java/com/epam/aidial/core/server/util/DeploymentEndpointUtil.java @@ -79,6 +79,22 @@ public boolean isInterfaceDeclared(Deployment deployment, InterfaceType type) { }; } + /** + * Whether the {@code interfaces} map contributes a servable type — an entry carrying a translator + * reference or a url of its own, the deployment-level {@code baseUrl} included, or one whose type a + * legacy field also serves. The map alone: a deployment routing only through legacy fields has no + * such interface, and an entry resolving to nothing is not one — the request path answers 503 for + * that same shape. + */ + public boolean hasRoutingInterface(Deployment deployment) { + for (InterfaceType type : InterfaceType.values()) { + if (findInterface(deployment, type) != null && isInterfaceDeclared(deployment, type)) { + return true; + } + } + return false; + } + /** * The absolute uri a deployments-POST request is forwarded to. Under {@code interfaces} that is the * ingress path appended to the base url, with the {@code /deployments/{id}/} segment rewritten to the diff --git a/server/src/test/java/com/epam/aidial/core/server/ApplicationInterfacesApiTest.java b/server/src/test/java/com/epam/aidial/core/server/ApplicationInterfacesApiTest.java index 4d878aa9c..df79c5a72 100644 --- a/server/src/test/java/com/epam/aidial/core/server/ApplicationInterfacesApiTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/ApplicationInterfacesApiTest.java @@ -6,6 +6,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * An application serves every interface type a model does — the deployment lookup behind @@ -18,6 +19,10 @@ class ApplicationInterfacesApiTest extends ResourceBaseTest { + "\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}"; private static final String MESSAGES_BODY = "{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\"," + "\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}"; + private static final String COMPLETIONS_BODY = "{\"id\":\"cmpl_1\",\"object\":\"chat.completion\"," + + "\"choices\":[{\"index\":0,\"finish_reason\":\"stop\"," + + "\"message\":{\"role\":\"assistant\",\"content\":\"hi\"}}]," + + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}"; @Test @DialConfigLocation("dial-config/application-interfaces.json") @@ -69,4 +74,44 @@ void applicationDeclaringOnlyChatCompletionsServesNothingElse() { assertEquals(503, response.status(), response.body()); } } + + /** + * The write API accepts the same shapes the config file does: an application whose only routing target + * is {@code interfaces} — here an entry claiming the deployment-level {@code baseUrl} — is stored and + * serves, with no legacy {@code endpoint} to satisfy the older validation. + */ + @Test + void applicationWrittenThroughTheApiServesInterfacesAsTheOnlyRoutingTarget() { + String completionsPath = "/openai/deployments/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST" + + "/interfaces-only-app/chat/completions"; + AtomicReference capturedPath = new AtomicReference<>(); + try (TestWebServer server = new TestWebServer(4848)) { + server.map(HttpMethod.POST, completionsPath, request -> { + capturedPath.set(request.getPath()); + return TestWebServer.createResponse(200, COMPLETIONS_BODY, "Content-Type", "application/json"); + }); + + Response created = send(HttpMethod.PUT, + "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/interfaces-only-app", null, + "{\"baseUrl\":\"http://localhost:4848\", \"interfaces\":{\"openaiChatCompletions\":{}}}"); + assertEquals(200, created.status(), created.body()); + + Response response = send(HttpMethod.POST, completionsPath, null, + "{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}], \"max_tokens\":16, \"stream\":false}", + "Content-Type", "application/json"); + + assertEquals(200, response.status(), response.body()); + assertEquals(completionsPath, capturedPath.get()); + } + } + + @Test + void anInterfacesEntryWithoutAnyUrlIsNoRoutingTarget() { + Response response = send(HttpMethod.PUT, + "/v1/applications/3CcedGxCx23EwiVbVmscVktScRyf46KypuBQ65miviST/no-url-app", null, + "{\"interfaces\":{\"openaiChatCompletions\":{}}}"); + + assertEquals(400, response.status(), response.body()); + assertTrue(response.body().contains("At least application endpoint"), response.body()); + } } diff --git a/server/src/test/java/com/epam/aidial/core/server/util/DeploymentEndpointUtilTest.java b/server/src/test/java/com/epam/aidial/core/server/util/DeploymentEndpointUtilTest.java index d88ec0abe..c06da89b6 100644 --- a/server/src/test/java/com/epam/aidial/core/server/util/DeploymentEndpointUtilTest.java +++ b/server/src/test/java/com/epam/aidial/core/server/util/DeploymentEndpointUtilTest.java @@ -17,6 +17,7 @@ import static com.epam.aidial.core.config.InterfaceType.OPENAI_CHAT_COMPLETIONS; import static com.epam.aidial.core.config.InterfaceType.OPENAI_EMBEDDINGS; import static com.epam.aidial.core.config.InterfaceType.OPENAI_RESPONSES; +import static com.epam.aidial.core.server.util.DeploymentEndpointUtil.hasRoutingInterface; import static com.epam.aidial.core.server.util.DeploymentEndpointUtil.isInterfaceDeclared; import static com.epam.aidial.core.server.util.DeploymentEndpointUtil.resolveMode; import static com.epam.aidial.core.server.util.DeploymentEndpointUtil.resolveRequestUri; @@ -282,6 +283,59 @@ void applicationsAndInterceptorsDeclareChatCompletionsFromTheLegacyEndpoint() { assertTrue(isInterfaceDeclared(interceptor, OPENAI_CHAT_COMPLETIONS)); } + @Test + void legacyFieldsAloneAreNoRoutingInterface() { + Application application = new Application(); + application.setEndpoint("http://host/chat/completions"); + + assertTrue(isInterfaceDeclared(application, OPENAI_CHAT_COMPLETIONS)); + assertFalse(hasRoutingInterface(application)); + } + + @Test + void anInterfacesEntryClaimingAnyBaseUrlRoutes() { + Application fromDeploymentBaseUrl = new Application(); + fromDeploymentBaseUrl.setBaseUrl("http://adapter:5000"); + fromDeploymentBaseUrl.setInterfaces(Map.of(OPENAI_CHAT_COMPLETIONS.getValue(), new DeploymentInterface())); + assertTrue(hasRoutingInterface(fromDeploymentBaseUrl)); + + Application fromOwnBaseUrl = new Application(); + DeploymentInterface entry = new DeploymentInterface(); + entry.setBaseUrl("http://adapter:5000/v1"); + fromOwnBaseUrl.setInterfaces(Map.of(OPENAI_CHAT_COMPLETIONS.getValue(), entry)); + assertTrue(hasRoutingInterface(fromOwnBaseUrl)); + } + + @Test + void interfacesEntryWithoutAnyUrlIsNoRoutingInterface() { + Application application = new Application(); + application.setInterfaces(Map.of(OPENAI_CHAT_COMPLETIONS.getValue(), new DeploymentInterface())); + + assertFalse(isInterfaceDeclared(application, OPENAI_CHAT_COMPLETIONS)); + assertFalse(hasRoutingInterface(application)); + } + + @Test + void translatedEntryRoutesByItsReferenceAlone() { + Application withReference = new Application(); + withReference.setInterfaces(Map.of(ANTHROPIC_MESSAGES.getValue(), + translated(TranslatorRef.named("anthropicMessagesToOpenaiChatCompletions")))); + assertTrue(hasRoutingInterface(withReference)); + + Application withoutReference = new Application(); + withoutReference.setInterfaces(Map.of(ANTHROPIC_MESSAGES.getValue(), translated(null))); + assertFalse(hasRoutingInterface(withoutReference)); + } + + @Test + void legacyResponsesFieldServesTheDeclaredResponsesEntry() { + Application application = new Application(); + application.setResponsesEndpoint("http://host/openai/v1/responses"); + application.setInterfaces(Map.of(OPENAI_RESPONSES.getValue(), new DeploymentInterface())); + + assertTrue(hasRoutingInterface(application)); + } + @Test void embeddingsPreferLegacyEndpointOverChatInterface() { Model model = new Model();