Inform AI engine which endpoints are disabled on the backend (#6251)

# Description of Changes
Have the Java send a list of enabled endpoints to the AI engine so it
can intelligently respond to the user that the tool does exist but is
disabled on the server so it can't acutally run the operation, instead
of the current behaviour where it sends the API call back and then 503
errors because the execution fails when the URL is disabled.

<img width="380" height="208" alt="image"
src="https://github.com/user-attachments/assets/5842fb2e-2e55-45a5-8205-25515636daae"
/>

---------

Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
James Brunton
2026-05-01 14:59:53 +00:00
committed by GitHub
co-authored by EthanHealy01
parent 5541dd666c
commit 51f5345151
13 changed files with 418 additions and 95 deletions
@@ -68,6 +68,36 @@ public class EndpointConfiguration {
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
}
/**
* Translate a full request URI like {@code /api/v1/general/remove-pages} into the endpoint key
* used by this configuration ({@code remove-pages}). Convert endpoints are a special case -
* {@code /api/v1/convert/pdf/img} is registered as {@code pdf-to-img}. Returns {@code null} if
* the URI is not an {@code /api/v1/<group>/<endpoint>} path.
*/
public static String endpointKeyForUri(String uri) {
if (uri == null || !uri.contains("/api/v1")) {
return null;
}
String[] parts = uri.split("/");
if (parts.length <= 4) {
return null;
}
if ("convert".equals(parts[3]) && parts.length > 5) {
return parts[4] + "-to-" + parts[5];
}
return parts[4];
}
/**
* Convenience wrapper around {@link #isEndpointEnabled(String)} that accepts a full request URI
* and translates it to the endpoint key. Falls back to treating the URI itself as a key for
* non-{@code /api/v1/...} paths so callers can pass arbitrary URIs.
*/
public boolean isEndpointEnabledForUri(String uri) {
String key = endpointKeyForUri(uri);
return isEndpointEnabled(key != null ? key : uri);
}
public void enableEndpoint(String endpoint) {
String normalized = normalizeEndpoint(endpoint);
endpointStatuses.put(normalized, true);
@@ -0,0 +1,39 @@
package stirling.software.SPDF.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.Test;
class EndpointConfigurationTest {
@Test
void endpointKeyForUriExtractsSimpleKebab() {
assertEquals(
"remove-pages",
EndpointConfiguration.endpointKeyForUri("/api/v1/general/remove-pages"));
assertEquals(
"compress-pdf",
EndpointConfiguration.endpointKeyForUri("/api/v1/misc/compress-pdf"));
assertEquals(
"add-watermark",
EndpointConfiguration.endpointKeyForUri("/api/v1/security/add-watermark"));
}
@Test
void endpointKeyForUriComposesConvertEndpoints() {
assertEquals(
"pdf-to-img", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/img"));
assertEquals(
"pdf-to-word", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/pdf/word"));
assertEquals(
"html-to-pdf", EndpointConfiguration.endpointKeyForUri("/api/v1/convert/html/pdf"));
}
@Test
void endpointKeyForUriReturnsNullForNonApiPaths() {
assertNull(EndpointConfiguration.endpointKeyForUri(null));
assertNull(EndpointConfiguration.endpointKeyForUri("/some-page"));
assertNull(EndpointConfiguration.endpointKeyForUri("/api/v1/general"));
}
}
@@ -21,28 +21,7 @@ public class EndpointInterceptor implements HandlerInterceptor {
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String requestURI = request.getRequestURI();
boolean isEnabled;
// Extract the specific endpoint name (e.g: /api/v1/general/remove-pages -> remove-pages)
if (requestURI.contains("/api/v1") && requestURI.split("/").length > 4) {
String[] requestURIParts = requestURI.split("/");
String requestEndpoint;
// Endpoint: /api/v1/convert/pdf/img becomes pdf-to-img
if ("convert".equals(requestURIParts[3]) && requestURIParts.length > 5) {
requestEndpoint = requestURIParts[4] + "-to-" + requestURIParts[5];
} else {
requestEndpoint = requestURIParts[4];
}
log.debug("Request endpoint: {}", requestEndpoint);
isEnabled = endpointConfiguration.isEndpointEnabled(requestEndpoint);
log.debug("Is endpoint enabled: {}", isEnabled);
} else {
isEnabled = endpointConfiguration.isEndpointEnabled(requestURI);
}
boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI);
if (!isEnabled) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
return false;
@@ -27,60 +27,19 @@ class EndpointInterceptorTest {
}
@Test
void preHandleAllowsEnabledApiEndpoint() throws Exception {
void preHandleAllowsEnabledEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(true);
when(endpointConfiguration.isEndpointEnabledForUri("/api/v1/general/remove-pages"))
.thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledApiEndpoint() throws Exception {
void preHandleBlocksDisabledEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/general/remove-pages");
when(endpointConfiguration.isEndpointEnabled("remove-pages")).thenReturn(false);
when(endpointConfiguration.isEndpointEnabledForUri("/api/v1/general/remove-pages"))
.thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
verify(response).sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
}
@Test
void preHandleExtractsConvertEndpointCorrectly() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledConvertEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/convert/pdf/img");
when(endpointConfiguration.isEndpointEnabled("pdf-to-img")).thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleUsesFullUriForNonApiPaths() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleBlocksDisabledNonApiPath() throws Exception {
when(request.getRequestURI()).thenReturn("/some-page");
when(endpointConfiguration.isEndpointEnabled("/some-page")).thenReturn(false);
assertFalse(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleUsesFullUriForShortApiPath() throws Exception {
// URI with /api/v1 but not enough segments (split length <= 4)
when(request.getRequestURI()).thenReturn("/api/v1/general");
when(endpointConfiguration.isEndpointEnabled("/api/v1/general")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandleExtractsNonConvertApiEndpoint() throws Exception {
when(request.getRequestURI()).thenReturn("/api/v1/security/add-watermark");
when(endpointConfiguration.isEndpointEnabled("add-watermark")).thenReturn(true);
assertTrue(interceptor.preHandle(request, response, new Object()));
}
}
@@ -34,11 +34,14 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiEngineEndpointResolver;
import stirling.software.proprietary.service.AiWorkflowService;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
@Slf4j
@RestController
@@ -53,6 +56,7 @@ public class AiEngineController {
private final Executor aiStreamExecutor;
private final TaskManager taskManager;
private final JobOwnershipService jobOwnershipService;
private final AiEngineEndpointResolver endpointResolver;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
@@ -68,13 +72,15 @@ public class AiEngineController {
ObjectMapper objectMapper,
@Qualifier("aiStreamExecutor") Executor aiStreamExecutor,
TaskManager taskManager,
JobOwnershipService jobOwnershipService) {
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
this.objectMapper = objectMapper;
this.aiStreamExecutor = aiStreamExecutor;
this.taskManager = taskManager;
this.jobOwnershipService = jobOwnershipService;
this.endpointResolver = endpointResolver;
}
@GetMapping("/health")
@@ -197,17 +203,36 @@ public class AiEngineController {
"Sends a user message to the PDF edit agent which returns a structured plan"
+ " of tool operations to perform")
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
validateJson(requestBody);
String response = aiEngineClient.post("/api/v1/pdf/edit", requestBody);
JsonNode parsed = parseJson(requestBody);
if (!parsed.isObject()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Request body must be a JSON object");
}
String forwardedBody = withEnabledEndpoints((ObjectNode) parsed);
String response = aiEngineClient.post("/api/v1/pdf/edit", forwardedBody);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}
private void validateJson(String body) {
private JsonNode parseJson(String body) {
try {
objectMapper.readValue(body, JsonNode.class);
return objectMapper.readValue(body, JsonNode.class);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
}
}
/**
* Always overwrite {@code enabled_endpoints} with the server's view of which endpoints are
* usable. The engine must not trust a client-supplied list - the gate is owned by the Java
* EndpointConfiguration. Values are full URL paths (e.g. {@code /api/v1/misc/compress-pdf})
* that the engine matches against its {@code ToolEndpoint} enum, silently dropping any it
* doesn't recognise (which lets the two sides drift in either direction without breaking).
*/
private String withEnabledEndpoints(ObjectNode body) {
ArrayNode enabled = objectMapper.createArrayNode();
endpointResolver.getEnabledEndpointUrls().forEach(enabled::add);
body.set("enabled_endpoints", enabled);
return body.toString();
}
}
@@ -0,0 +1,87 @@
package stirling.software.proprietary.service;
import java.lang.reflect.Method;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
/**
* Discovers every {@code /api/v1/...} request mapping in the application and exposes the subset
* that {@link EndpointConfiguration} reports as currently enabled. The AI engine receives this list
* as-is and silently drops anything it doesn't recognise, so we don't try to predict what the
* engine considers a tool - we just emit what's enabled here.
*/
@Slf4j
@Service
public class AiEngineEndpointResolver {
private static final String API_PREFIX = "/api/v1/";
private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
// Written once on the Spring startup thread during ContextRefreshedEvent, read on HTTP
// request threads. Spring's lifecycle establishes happens-before (the servlet container
// and its worker threads are started after refresh completes), so no volatile is needed.
private Set<String> apiUrls = Set.of();
public AiEngineEndpointResolver(
ApplicationContext applicationContext, EndpointConfiguration endpointConfiguration) {
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
}
@EventListener(ContextRefreshedEvent.class)
public void discoverApiUrls() {
Set<String> discovered = new TreeSet<>();
for (RequestMappingHandlerMapping mapping :
applicationContext.getBeansOfType(RequestMappingHandlerMapping.class).values()) {
for (RequestMappingInfo info : mapping.getHandlerMethods().keySet()) {
for (String pattern : extractPatterns(info)) {
if (pattern.startsWith(API_PREFIX)) {
discovered.add(pattern);
}
}
}
}
apiUrls = Set.copyOf(discovered);
log.debug("Discovered {} /api/v1/ endpoint URLs for AI engine filtering", apiUrls.size());
}
public List<String> getEnabledEndpointUrls() {
return apiUrls.stream()
.filter(endpointConfiguration::isEndpointEnabledForUri)
.sorted()
.toList();
}
private static Set<String> extractPatterns(RequestMappingInfo info) {
try {
Method getDirectPaths = info.getClass().getMethod("getDirectPaths");
Object result = getDirectPaths.invoke(info);
if (result instanceof Set<?> set) {
Set<String> patterns = new HashSet<>();
for (Object value : set) {
if (value instanceof String s) {
patterns.add(s);
}
}
return patterns;
}
} catch (Exception e) {
log.trace("getDirectPaths unavailable on RequestMappingInfo", e);
}
return Set.of();
}
}
@@ -71,6 +71,7 @@ public class AiWorkflowService {
private final ToolMetadataService toolMetadataService;
private final TempFileManager tempFileManager;
private final FileIdStrategy fileIdStrategy;
private final AiEngineEndpointResolver endpointResolver;
@FunctionalInterface
public interface ProgressListener {
@@ -125,6 +126,7 @@ public class AiWorkflowService {
request.getConversationHistory() == null
? new ArrayList<>()
: new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
@@ -219,6 +221,7 @@ public class AiWorkflowService {
nextRequest.setConversationHistory(request.getConversationHistory());
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setEnabledEndpoints(request.getEnabledEndpoints());
return new WorkflowState.Pending(nextRequest);
} finally {
for (LoadedFile lf : loadedFiles) {
@@ -624,5 +627,6 @@ public class AiWorkflowService {
private List<AiConversationMessage> conversationHistory = new ArrayList<>();
private List<WorkflowArtifact> artifacts = new ArrayList<>();
private String resumeWith;
private List<String> enabledEndpoints = new ArrayList<>();
}
}
@@ -79,6 +79,7 @@ class AiWorkflowServiceTest {
@Mock private FileStorage fileStorage;
@Mock private ToolMetadataService toolMetadataService;
@Mock private FileIdStrategy fileIdStrategy;
@Mock private AiEngineEndpointResolver endpointResolver;
@TempDir Path tempDir;
@@ -110,7 +111,9 @@ class AiWorkflowServiceTest {
fileStorage,
toolMetadataService,
tempFileManager,
fileIdStrategy);
fileIdStrategy,
endpointResolver);
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
}
@Test