Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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<String> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down