This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 75d7e32c61a40e7393c4311eccf1f86c7ec6a90d Author: Luigi De Masi <[email protected]> AuthorDate: Wed Oct 14 23:24:55 2026 +0200 CAMEL-25669: Enforce expert-configured image limits across runtime and tooling Configure maxImageBytes on the expert bean with a 10 MB OpenAI default. Publish the limit in metadata, bound CLI/TUI file reads and route stream caching, and reject oversized inline images before evaluation. Document the validation boundary and cover malformed base64 bodies before inference. Co-authored-by: OpenAI Codex <[email protected]> Signed-off-by: Luigi De Masi <[email protected]> --- .../apache/camel/catalog/docs/semantic-openai.adoc | 18 ++++ .../src/main/docs/semantic-openai.adoc | 18 ++++ .../openai/OpenAIDecisionsSemanticAdapter.java | 16 +++ .../openai/OpenAIDecisionsSemanticAdapterTest.java | 109 +++++++++++++++++++++ .../camel/language/semantic/SemanticLanguage.java | 9 +- .../org/apache/camel/semantic/SemanticAdapter.java | 9 ++ .../org/apache/camel/semantic/SemanticInput.java | 71 +++++++++++++- .../camel/semantic/SemanticMetadataConsole.java | 18 ++-- .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 5 + .../modules/ROOT/pages/camel-jbang-managing.adoc | 9 ++ .../ROOT/pages/camel-jbang-tui-observe.adoc | 7 ++ .../jbang/core/commands/action/CamelSemantic.java | 1 + .../commands/action/SemanticActionCommand.java | 9 ++ .../core/commands/action/SemanticEvaluate.java | 48 ++++++++- .../dsl/jbang/core/common/SemanticMediaInput.java | 80 ++++++++++++++- .../core/commands/action/SemanticCommandTest.java | 77 +++++++++++++-- .../jbang/core/common/SemanticMediaInputTest.java | 44 ++++++++- .../jbang/core/commands/tui/SemanticDetails.java | 3 + .../core/commands/tui/SemanticEvaluationPopup.java | 9 +- .../core/commands/tui/SemanticPlayground.java | 9 +- .../core/commands/tui/SemanticImageViewTest.java | 49 ++++++++- 21 files changed, 587 insertions(+), 31 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-openai.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-openai.adoc index 40d8325df015..eaeb98334c49 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-openai.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-openai.adoc @@ -43,6 +43,7 @@ camel.component.openai.api-key={{env:OPENAI_API_KEY}} type: org.apache.camel.semantic.openai.OpenAIDecisionsSemanticAdapter properties: endpointUri: "openai:decisions?model=gpt-6-luna" + maxImageBytes: 10000000 - semantic: evaluation: damaged: @@ -73,6 +74,15 @@ When constructing the bean in Java, set its `CamelContext` before binding it to Every evaluation must explicitly name an expert. Capability inspection and declaration validation do not perform inference. +`maxImageBytes` is a positive integer configured on the expert bean, defaulting to 10 MB +(10,000,000 bytes) per image before base64 encoding. For example, set `maxImageBytes: 20000000` +to permit images up to 20 MB. Configure it before evaluation. The expert enforces this limit +for route input and direct calls, including inline data URLs and mixed media envelopes. +The CLI and TUI obtain the value from the expert's runtime metadata and check it before +loading local files or submitting image input. There is no separate CLI or TUI setting. +A batch uses the smallest declared image limit among its experts when normalizing a shared body. +Base64 encoding uses additional memory; provider input limits still apply. + == Input Supported modalities are text, images and ordered combinations of text and images. @@ -95,6 +105,9 @@ data:image/png;base64,iVBORw0KGgo... The example is abbreviated. Plain strings without an image content type remain text; Camel does not guess whether an arbitrary string is base64. Content-Type applies when the selected state is `$\{body}`; it does not reclassify a separately selected header or variable. +An image String body must contain base64-encoded bytes, not ordinary text. The Semantic +input helper creates the media envelope; the OpenAI expert validates base64 syntax and +provider input limits before inference, rejecting empty or malformed payloads locally. Streams are cached and reset before evaluation. Downstream steps receive a replayable stream cache containing the same bytes. Byte arrays and strings keep their original body identity. @@ -174,6 +187,11 @@ fits the budget. A descriptor is not a replayable image. Captured content remain `--input=image:/path/picture.png` for a direct expert call. Use `json:` for an ordered envelope. The TUI playground accepts `image:/path/picture.png` and JSON envelopes when the expert advertises image input. Files are read only on an explicit evaluation request. +For local `image:` input, the CLI checks the selected runtime expert's metadata first; +the TUI uses the selected operation's advertised limit. Both check file size and bound the +read using the expert's `maxImageBytes`, including when a file grows during reading. +If an expert does not advertise image support and a positive limit, local image loading +is rejected. These checks do not change the separate audit-preview bounds. The TUI Audit inspector (`u`) renders bounded inline colour previews from retained image data. The CLI renders a plain-terminal preview. Decoding uses locally available ImageIO readers; diff --git a/components/camel-ai/camel-semantic-openai/src/main/docs/semantic-openai.adoc b/components/camel-ai/camel-semantic-openai/src/main/docs/semantic-openai.adoc index 40d8325df015..eaeb98334c49 100644 --- a/components/camel-ai/camel-semantic-openai/src/main/docs/semantic-openai.adoc +++ b/components/camel-ai/camel-semantic-openai/src/main/docs/semantic-openai.adoc @@ -43,6 +43,7 @@ camel.component.openai.api-key={{env:OPENAI_API_KEY}} type: org.apache.camel.semantic.openai.OpenAIDecisionsSemanticAdapter properties: endpointUri: "openai:decisions?model=gpt-6-luna" + maxImageBytes: 10000000 - semantic: evaluation: damaged: @@ -73,6 +74,15 @@ When constructing the bean in Java, set its `CamelContext` before binding it to Every evaluation must explicitly name an expert. Capability inspection and declaration validation do not perform inference. +`maxImageBytes` is a positive integer configured on the expert bean, defaulting to 10 MB +(10,000,000 bytes) per image before base64 encoding. For example, set `maxImageBytes: 20000000` +to permit images up to 20 MB. Configure it before evaluation. The expert enforces this limit +for route input and direct calls, including inline data URLs and mixed media envelopes. +The CLI and TUI obtain the value from the expert's runtime metadata and check it before +loading local files or submitting image input. There is no separate CLI or TUI setting. +A batch uses the smallest declared image limit among its experts when normalizing a shared body. +Base64 encoding uses additional memory; provider input limits still apply. + == Input Supported modalities are text, images and ordered combinations of text and images. @@ -95,6 +105,9 @@ data:image/png;base64,iVBORw0KGgo... The example is abbreviated. Plain strings without an image content type remain text; Camel does not guess whether an arbitrary string is base64. Content-Type applies when the selected state is `$\{body}`; it does not reclassify a separately selected header or variable. +An image String body must contain base64-encoded bytes, not ordinary text. The Semantic +input helper creates the media envelope; the OpenAI expert validates base64 syntax and +provider input limits before inference, rejecting empty or malformed payloads locally. Streams are cached and reset before evaluation. Downstream steps receive a replayable stream cache containing the same bytes. Byte arrays and strings keep their original body identity. @@ -174,6 +187,11 @@ fits the budget. A descriptor is not a replayable image. Captured content remain `--input=image:/path/picture.png` for a direct expert call. Use `json:` for an ordered envelope. The TUI playground accepts `image:/path/picture.png` and JSON envelopes when the expert advertises image input. Files are read only on an explicit evaluation request. +For local `image:` input, the CLI checks the selected runtime expert's metadata first; +the TUI uses the selected operation's advertised limit. Both check file size and bound the +read using the expert's `maxImageBytes`, including when a file grows during reading. +If an expert does not advertise image support and a positive limit, local image loading +is rejected. These checks do not change the separate audit-preview bounds. The TUI Audit inspector (`u`) renders bounded inline colour previews from retained image data. The CLI renders a plain-terminal preview. Decoding uses locally available ImageIO readers; diff --git a/components/camel-ai/camel-semantic-openai/src/main/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapter.java b/components/camel-ai/camel-semantic-openai/src/main/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapter.java index d1c477e9b3fa..b0c36dfe9710 100644 --- a/components/camel-ai/camel-semantic-openai/src/main/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapter.java +++ b/components/camel-ai/camel-semantic-openai/src/main/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapter.java @@ -119,6 +119,7 @@ public class OpenAIDecisionsSemanticAdapter implements SemanticAdapter, CamelCon private static final SemanticCapabilities CAPABILITIES = SemanticCapabilities.from(OpenAIDecisionsSemanticAdapter.class); private CamelContext camelContext; private String endpointUri = "openai:decisions"; + private int maxImageBytes = 10_000_000; private volatile OpenAIEndpoint endpoint; @Override @@ -135,6 +136,19 @@ public class OpenAIDecisionsSemanticAdapter implements SemanticAdapter, CamelCon return endpointUri; } + @Override + public int getMaxImageBytes() { + return maxImageBytes; + } + + /** Maximum bytes per image before base64 encoding. Defaults to 10 MB. Configure before evaluation. */ + public void setMaxImageBytes(int maxImageBytes) { + if (maxImageBytes <= 0) { + throw new IllegalArgumentException("maxImageBytes must be greater than zero"); + } + this.maxImageBytes = maxImageBytes; + } + /** Configured Decisions endpoint, including model and transport options. Set before the first evaluation. */ public synchronized void setEndpointUri(String endpointUri) { if (endpoint != null || endpointUri == null || endpointUri.isBlank()) { @@ -212,6 +226,7 @@ public class OpenAIDecisionsSemanticAdapter implements SemanticAdapter, CamelCon @Override public void validateState(Object state) { + SemanticInput.validateImageSize(SemanticInput.normalize(state), maxImageBytes); OpenAIDecisionsSupport.validateInput(input(SemanticInput.normalize(state))); } @@ -227,6 +242,7 @@ public class OpenAIDecisionsSemanticAdapter implements SemanticAdapter, CamelCon throw new IllegalArgumentException("OpenAI Decisions requires 1 to 200 evaluations per batch"); } Object input = SemanticInput.normalize(state); + SemanticInput.validateImageSize(input, maxImageBytes); evaluations.forEach((name, evaluation) -> { text(name); validate(evaluation); diff --git a/components/camel-ai/camel-semantic-openai/src/test/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapterTest.java b/components/camel-ai/camel-semantic-openai/src/test/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapterTest.java index 0e328d589780..9fb44533ab13 100644 --- a/components/camel-ai/camel-semantic-openai/src/test/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapterTest.java +++ b/components/camel-ai/camel-semantic-openai/src/test/java/org/apache/camel/semantic/openai/OpenAIDecisionsSemanticAdapterTest.java @@ -18,9 +18,11 @@ package org.apache.camel.semantic.openai; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import com.openai.client.OpenAIClient; @@ -36,6 +38,7 @@ import org.apache.camel.component.openai.OpenAIDecisionRefusalException; import org.apache.camel.component.openai.OpenAIDecisionServiceException; import org.apache.camel.component.openai.OpenAIEndpoint; import org.apache.camel.component.openai.OpenAIOperations; +import org.apache.camel.console.DevConsole; import org.apache.camel.impl.DefaultCamelContext; import org.apache.camel.language.semantic.SemanticLanguage; import org.apache.camel.semantic.SemanticCapabilities; @@ -45,6 +48,8 @@ import org.apache.camel.semantic.SemanticInput; import org.apache.camel.semantic.SemanticResult; import org.apache.camel.semantic.internal.SemanticAuditExtensions; import org.apache.camel.support.DefaultExchange; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.util.json.JsonObject; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -133,6 +138,97 @@ class OpenAIDecisionsSemanticAdapterTest { verify(client.decisions(), times(5)).create(any(DecisionCreateParams.class)); } + @Test + void imageLimitAppliesToRouteBodiesAndDirectCalls() throws Exception { + assertThat(adapter.getMaxImageBytes()).isEqualTo(10_000_000); + adapter.setMaxImageBytes(2); + Expression expression = expression(evaluation("boolean", Map.of())); + byte[] bytes = { 1, 2, 3 }; + for (Object body : List.of(bytes, new ByteArrayInputStream(bytes), "AQID", "data:image/png;base64,AQID")) { + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody(body); + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "image/png"); + assertThatThrownBy(() -> expression.evaluate(exchange, Boolean.class)).hasMessageContaining("maxImageBytes"); + } + var media = SemanticInput.ofParts(List.of(SemanticInput.image("data:image/png;base64,AQID"))); + assertThatThrownBy(() -> adapter.evaluate(evaluation("boolean", Map.of()), media)) + .hasMessageContaining("maxImageBytes"); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + adapter.setMaxImageBytes(3); + respond = request -> response(predicate("question", 0.9)); + assertThat(adapter.evaluate(evaluation("boolean", Map.of()), media).getValue()).isEqualTo(true); + adapter.setMaxImageBytes(1); + var padded = SemanticInput.ofParts(List.of(SemanticInput.image("data:image/png;base64,AQ=="))); + assertThat(adapter.evaluate(evaluation("boolean", Map.of()), padded).getValue()).isEqualTo(true); + } + + @Test + void metadataAdvertisesEachExpertConfiguredImageLimitWithoutInference() { + adapter.setMaxImageBytes(3); + var other = new OpenAIDecisionsSemanticAdapter(); + other.setMaxImageBytes(7); + context.getRegistry().bind("other", other); + DevConsole console = PluginHelper.getDevConsoleResolver(context).resolveDevConsole("semantic-metadata"); + for (String reference : List.of("expert", "other")) { + JsonObject response = (JsonObject) console.call(DevConsole.MediaType.JSON, Map.of("expert", reference)); + for (Object value : response.getCollection("operations")) { + assertThat(((JsonObject) value).getJsonObject("contract").getInteger("maxImageBytes")) + .isEqualTo("expert".equals(reference) ? 3 : 7); + } + } + JsonObject overview = (JsonObject) console.call(DevConsole.MediaType.JSON, Map.of("overview", true)); + assertThat(overview.toJson()).contains("maxImageBytes"); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + + @Test + void imageLimitMustBePositive() { + assertThatThrownBy(() -> adapter.setMaxImageBytes(0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> adapter.setMaxImageBytes(-1)).isInstanceOf(IllegalArgumentException.class); + assertThat(adapter.getMaxImageBytes()).isEqualTo(10_000_000); + } + + @Test + void oversizedStreamsStopBeforeBeingFullyCached() { + adapter.setMaxImageBytes(3); + var consumed = new AtomicInteger(); + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody(new InputStream() { + @Override + public int read() { + if (consumed.incrementAndGet() > 64) { + throw new AssertionError("Image stream was read without a bound"); + } + return 0; + } + }); + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "image/png"); + Expression expression = expression(evaluation("boolean", Map.of())); + assertThatThrownBy(() -> expression.evaluate(exchange, Boolean.class)).hasStackTraceContaining("maxImageBytes"); + assertThat(consumed.get()).isEqualTo(4); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + + @Test + void sharedBodyUsesTheSmallestExpertLimitBeforeAnyBatchInference() { + adapter.setMaxImageBytes(3); + var strict = new OpenAIDecisionsSemanticAdapter(); + strict.setCamelContext(context); + strict.setMaxImageBytes(2); + context.getRegistry().bind("strict", strict); + var evaluations = new LinkedHashMap<String, SemanticEvaluation>(); + evaluations.put("first", evaluation("boolean", Map.of())); + evaluations.put("second", new SemanticEvaluation("boolean", "strict", null, Map.of("instructions", "Assess"))); + SemanticEvaluations.get(context).replace("batch", evaluations); + var expression = context.resolveLanguage("semantic").createExpression("refs:first,second"); + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody(new byte[] { 1, 2, 3 }); + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "image/png"); + assertThatThrownBy(() -> expression.evaluate(exchange, Map.class)) + .hasMessageContaining("maxImageBytes limit of 2 bytes"); + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + @Test void selectedTextHeaderDoesNotInheritTheImageBodyContentType() { var evaluation = new SemanticEvaluation("boolean", "expert", "${header.evidence}", Map.of("instructions", "Assess")); @@ -288,6 +384,19 @@ class OpenAIDecisionsSemanticAdapterTest { verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); } + @Test + void malformedBase64ImageBodiesAreRejectedBeforeInference() { + Expression expression = expression(evaluation("boolean", Map.of())); + for (String body : List.of("", " ", "invalid!", "hello world", "not valid image payload\n".repeat(100_000))) { + var exchange = new DefaultExchange(context); + exchange.getMessage().setBody(body); + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "image/png"); + assertThatThrownBy(() -> expression.evaluate(exchange, Boolean.class)) + .hasRootCauseInstanceOf(IllegalArgumentException.class).hasMessageContaining("base64"); + } + verify(client.decisions(), never()).create(any(DecisionCreateParams.class)); + } + @Test void requiresAnExplicitModelBeforeInference() { context.getEndpoint("openai:decisions", OpenAIEndpoint.class).getConfiguration().setModel(null); diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java index 19b7edbf8ef2..4bc97b8b3f9e 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java @@ -173,6 +173,11 @@ public class SemanticLanguage extends LanguageSupport { return describeExpert(expert).capabilities(); } + /** Read the named expert's configured image limit without performing inference. */ + public int getExpertMaxImageBytes(String expert) { + return expertBean(expertReference(expert)).getMaxImageBytes(); + } + /** The selected registry reference and its static contract. */ public record ExpertMetadata(String reference, SemanticCapabilities capabilities) { } @@ -442,7 +447,9 @@ public class SemanticLanguage extends LanguageSupport { active.addAll(invocations.values()); failureReason = "invalid_input"; Object state = current.body - ? SemanticInput.body(exchange) + ? SemanticInput.body(exchange, + current.groups.stream().mapToInt(g -> g.expert.provider.getMaxImageBytes()) + .filter(limit -> limit > 0).min().orElse(Integer.MAX_VALUE)) : SemanticInput.normalize(current.state.evaluate(exchange, Object.class)); invocations.values().forEach(invocation -> invocation.captureInput(state)); // A state selector can itself evaluate another semantic expression. diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticAdapter.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticAdapter.java index d3158424a767..1604ea712780 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticAdapter.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticAdapter.java @@ -29,6 +29,15 @@ import java.util.Map; * does not instantiate, discover or manage provider services. */ public interface SemanticAdapter { + /** + * Configured maximum bytes per image before base64 encoding, or zero when no limit is advertised. Tooling uses this + * value before reading local files; image experts must also enforce it before inference. Inspection must not + * contact services. This is instance configuration, separate from the static operation contract. + */ + default int getMaxImageBytes() { + return 0; + } + /** Reject unsupported evaluation kinds, criteria, decision policies or input selectors before traffic starts. */ void validate(SemanticEvaluation evaluation); diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticInput.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticInput.java index b237100bdbae..4e9efcf8a4fa 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticInput.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticInput.java @@ -16,6 +16,8 @@ */ package org.apache.camel.semantic; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; import java.util.Base64; import java.util.List; @@ -66,9 +68,15 @@ public final class SemanticInput { /** * Select an image body using Content-Type. Streams become replayable Camel stream caches before reading, so later - * route steps can still consume the body. Headers do not affect explicitly selected headers or variables. + * route steps can still consume the body. Headers do not affect explicitly selected headers or variables. String + * image bodies contain base64 or an image data URL; the selected expert validates the image data and limits. */ public static Object body(Exchange exchange) throws NoTypeConversionAvailableException { + return body(exchange, Integer.MAX_VALUE); + } + + /** Normalize an image body within the selected experts' per-image byte limit. */ + public static Object body(Exchange exchange, int maxImageBytes) throws NoTypeConversionAvailableException { Object input = exchange.getMessage().getBody(); String contentType = exchange.getMessage().getHeader(Exchange.CONTENT_TYPE, String.class); String mediaType = contentType == null ? "" : contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); @@ -81,23 +89,28 @@ public final class SemanticInput { if (!mediaType.matches("image/[a-z0-9.+-]{1,100}")) { throw new IllegalArgumentException("Invalid image Content-Type"); } - if (input instanceof InputStream && !(input instanceof StreamCache)) { - input = exchange.getContext().getTypeConverter().mandatoryConvertTo(StreamCache.class, exchange, input); + if (input instanceof InputStream stream && !(input instanceof StreamCache)) { + input = exchange.getContext().getTypeConverter().mandatoryConvertTo(StreamCache.class, exchange, + limitedImageStream(stream, maxImageBytes)); exchange.getMessage().setBody(input); } if (input instanceof StreamCache cache) { try { cache.reset(); + checkImageSize(cache.length(), maxImageBytes); byte[] data = exchange.getContext().getTypeConverter().mandatoryConvertTo(byte[].class, exchange, cache); + checkImageSize(data.length, maxImageBytes); return ofParts(List.of(image(data, mediaType))); } finally { cache.reset(); } } if (input instanceof byte[] data) { + checkImageSize(data.length, maxImageBytes); return ofParts(List.of(image(data, mediaType))); } if (input instanceof String text) { + checkImageSize(imageBytes(text), maxImageBytes); return text.startsWith("data:image/") ? normalize(text) : ofParts(List.of(image("data:" + mediaType + ";base64," + text))); @@ -108,6 +121,58 @@ public final class SemanticInput { throw new IllegalArgumentException("An image body requires bytes, an InputStream, or a base64 string"); } + /** + * Check decoded image sizes without decoding or duplicating payloads; encoding validation belongs to the expert. + */ + public static void validateImageSize(Object input, int maxImageBytes) { + if (isMedia(input)) { + for (Object item : parts(input)) { + if (item instanceof Map<?, ?> part && "image".equals(part.get("type")) + && part.get("data") instanceof String data) { + checkImageSize(imageBytes(data), maxImageBytes); + } + } + } + } + + private static long imageBytes(String data) { + int start = data.startsWith("data:") ? data.indexOf(';') : -1; + start = start >= 0 && data.startsWith(";base64,", start) ? start + 8 : 0; + int padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; + return (data.length() - start) * 3L / 4 - padding; + } + + private static void checkImageSize(long bytes, int maxImageBytes) { + if (bytes > maxImageBytes) { + throw new IllegalArgumentException("Image exceeds the expert maxImageBytes limit of " + maxImageBytes + " bytes"); + } + } + + private static InputStream limitedImageStream(InputStream stream, int maxImageBytes) { + return new FilterInputStream(stream) { + private long count; + + @Override + public int read() throws IOException { + int value = in.read(); + if (value != -1) { + checkImageSize(++count, maxImageBytes); + } + return value; + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + int read = in.read(bytes, offset, (int) Math.min(length, maxImageBytes - count + 1)); + if (read > 0) { + count += read; + checkImageSize(count, maxImageBytes); + } + return read; + } + }; + } + public static boolean isMedia(Object input) { return input instanceof Map<?, ?> map && map.containsKey(PARTS); } diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticMetadataConsole.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticMetadataConsole.java index 3b84ab61f114..fbc41df5a293 100644 --- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticMetadataConsole.java +++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticMetadataConsole.java @@ -29,7 +29,7 @@ import org.apache.camel.spi.annotations.DevConsole; import org.apache.camel.support.console.AbstractDevConsole; import org.apache.camel.util.json.JsonRecordSupport; -/** Static operation metadata and published declarations for runtime tooling. */ +/** Operation metadata, configured input limits and published declarations for runtime tooling. */ @DevConsole(name = "semantic-metadata", displayName = "Semantic Metadata", description = "Semantic experts and evaluation definitions") public class SemanticMetadataConsole extends AbstractDevConsole { @@ -90,7 +90,8 @@ public class SemanticMetadataConsole extends AbstractDevConsole { } List<Operation> operations; try { - operations = operations(language.getExpertCapabilities(optionString(options, EXPERT))); + String reference = optionString(options, EXPERT); + operations = operations(language.getExpertCapabilities(reference), language.getExpertMaxImageBytes(reference)); } catch (IllegalArgumentException unavailable) { operations = List.of(); } @@ -101,7 +102,7 @@ public class SemanticMetadataConsole extends AbstractDevConsole { Map<String, Expert> experts = new TreeMap<>(); getCamelContext().getRegistry().findByTypeWithName(SemanticAdapter.class).keySet().forEach(reference -> { try { - addExpert(experts, language.describeExpert(reference)); + addExpert(experts, language.describeExpert(reference), language.getExpertMaxImageBytes(reference)); } catch (IllegalArgumentException e) { experts.put(reference, new Expert(reference, null, null, null, null, List.of(), e.getMessage())); } @@ -115,7 +116,7 @@ public class SemanticMetadataConsole extends AbstractDevConsole { String error = null; try { var selected = language.describeExpert(reference); - reference = addExpert(experts, selected); + reference = addExpert(experts, selected, language.getExpertMaxImageBytes(selected.reference())); resultType = selected.capabilities().operation(evaluation.getOperation()).getResultType().name() .toLowerCase(Locale.ROOT); } catch (IllegalArgumentException e) { @@ -130,20 +131,23 @@ public class SemanticMetadataConsole extends AbstractDevConsole { return new Response(null, List.copyOf(experts.values()), definitions); } - private static String addExpert(Map<String, Expert> experts, SemanticLanguage.ExpertMetadata metadata) { + private static String addExpert(Map<String, Expert> experts, SemanticLanguage.ExpertMetadata metadata, int maxImageBytes) { SemanticCapabilities capabilities = metadata.capabilities(); experts.putIfAbsent(metadata.reference(), new Expert( metadata.reference(), capabilities.getName(), capabilities.getDescription(), capabilities.getProvider(), capabilities.getArtifactId(), - operations(capabilities), null)); + operations(capabilities, maxImageBytes), null)); return metadata.reference(); } - private static List<Operation> operations(SemanticCapabilities capabilities) { + private static List<Operation> operations(SemanticCapabilities capabilities, int maxImageBytes) { return capabilities.getOperations().values().stream().map(op -> { Map<String, Object> contract = new LinkedHashMap<>(); contract.put("inputTypes", op.getInputTypes().stream().map(type -> type.name().toLowerCase(Locale.ROOT)).sorted().toList()); + if (maxImageBytes > 0 && op.getInputTypes().contains(SemanticExpert.InputType.IMAGE)) { + contract.put("maxImageBytes", maxImageBytes); + } contract.put("inputRequirements", op.getInputRequirements()); contract.put("resultMeaning", op.getResultMeaning()); if (!op.getScoreLevelsParameter().isEmpty()) { diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 01548ec434a0..50374dc80224 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -743,6 +743,11 @@ all questions for one expert. Existing per-evaluation `validateInput` hooks stil for every expert before any provider performs inference. A shared validation failure marks that expert's group as invalid input in the audit. +The additive `SemanticAdapter.getMaxImageBytes()` method exposes an expert's configured per-image +byte limit, with zero meaning no advertised limit. Image-capable adapters should advertise a +positive limit and enforce it before inference. CLI and TUI local image loading now requires +that metadata; the configured value appears as `maxImageBytes` in each image operation's contract. + Semantic audit backends can opt into the additive `inputType` extension. Readers that do not support it keep their existing projection. The `camel semantic audit --json` display omits image base64 and reports metadata instead. Use `--output=<new-file>` to export the full returned JSON, diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc index 43e56e14f43b..bd7ccaabf7a5 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-managing.adoc @@ -343,6 +343,15 @@ example `--parameter=threshold=json:0.7`, `--variable=enabled=json:true`, or with `json:` or `file:`. `--body=file:sample.txt` and `--input=file:sample.txt` read the file as UTF-8 text. +For an expert accepting images, `--body=image:/path/picture.png` or +`--input=image:/path/picture.png` reads a local PNG, JPEG, GIF or WebP file. Before reading, +the CLI queries the selected expert's runtime metadata for its `maxImageBytes` limit. +It checks both the file size and the actual read, and checks inline images before submitting +an evaluation. Configure the limit on the expert bean; the OpenAI Decisions expert defaults +to 10 MB (10,000,000 bytes) per image before base64 encoding. Both the CLI and TUI use that +same configuration. Missing image support or limit metadata prevents local image loading; +no evaluation is submitted. Use a JSON string to pass a literal `image:` prefix. + Evaluation prints the value, available probability or probabilities, confidence, provider metadata, elapsed milliseconds and any error. It invokes the configured provider and may incur charges. `--evaluation-timeout` bounds runtime evaluation diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc index 9e5ab04496cf..2907c2e4b798 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc @@ -552,6 +552,13 @@ envelope; the file is read only when Run is requested. Unchanged text loaded fro literal text, including an `image:` prefix. Definition samples also accept `"body":"image:/path/picture.png"`. +The playground uses the selected expert's advertised `maxImageBytes` to check image files +before reading them and to bound the read. Inline image payloads are also checked before +submission. Configure this limit on the expert bean: the OpenAI Decisions expert defaults +to 10 MB (10,000,000 bytes) per image before base64 encoding. The CLI and route evaluations +use the same expert configuration. Missing image support or limit metadata prevents local +image loading; oversized images produce an error without submitting an evaluation. + The latest page refreshes every second; `Space` pauses or resumes polling. Older pages stay fixed until explicitly refreshed. Filters use exact values; `definition` and `operation` apply to all retained history. `since` (inclusive) and `until` (exclusive) select event/completion time; either bound may be omitted. diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java index 5956701f1341..e0f76a0293fa 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSemantic.java @@ -111,6 +111,7 @@ public class CamelSemantic extends SemanticActionCommand { continue; } renderField("Input", contract.get("inputTypes"), " "); + renderField("Maximum image bytes", contract.get("maxImageBytes"), " "); renderField("Requirements", contract.get("inputRequirements"), " "); renderField("Result", contract.get("resultMeaning"), " "); renderField("Labels", contract.get("labels"), " "); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java index 5d447044bf10..75925e316cbd 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticActionCommand.java @@ -76,6 +76,11 @@ public abstract class SemanticActionCommand extends ActionBaseCommand implements + " runs Camel " + version, null); } + try { + prepareRequest(pid, request); + } catch (IllegalArgumentException e) { + return error(2, e.getMessage(), null); + } JsonObject response = RuntimeHelper.executeAction(pid, request, timeout); if (response == null) { return error(4, "No reply from integration within " + timeout + " milliseconds", null); @@ -178,6 +183,10 @@ public abstract class SemanticActionCommand extends ActionBaseCommand implements return Character.toUpperCase(label.charAt(0)) + label.substring(1).replace(" Id", " ID"); } + /** Prepare input requiring the selected runtime's metadata before submitting an action. */ + protected void prepareRequest(long pid, JsonObject request) throws Exception { + } + protected abstract JsonObject request(); protected abstract void render(JsonObject response); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java index 94ddf41c8e21..95bba0f098e9 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticEvaluate.java @@ -20,11 +20,13 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.RuntimeHelper; import org.apache.camel.dsl.jbang.core.common.SemanticMediaInput; import org.apache.camel.util.json.JsonObject; import picocli.CommandLine; @@ -124,6 +126,49 @@ public class SemanticEvaluate extends SemanticActionCommand { return request; } + @Override + protected void prepareRequest(long pid, JsonObject request) throws Exception { + String selected = evaluation == null ? input : body; + String inputKey = evaluation == null ? "input" : "body"; + Object value = request.get(inputKey); + Object messageHeaders = evaluation == null ? null : request.get("headers"); + boolean localImage = selected != null && selected.startsWith("image:"); + if (!localImage && !SemanticMediaInput.hasImages(value, messageHeaders)) { + return; + } + JsonObject metadata = RuntimeHelper.executeAction(pid, + new JsonObject(Map.of("action", "semantic-metadata", "overview", true)), timeout); + if (metadata == null || metadata.isEmpty()) { + throw new IllegalArgumentException("Cannot read the selected expert's image limit from runtime metadata"); + } + String reference = expert; + String operationName = operation; + if (evaluation != null) { + JsonObject definition = named(metadata, "evaluations", "name", evaluation); + reference = definition.getString("expert"); + operationName = definition.getString("operation"); + } + JsonObject selectedExpert = named(metadata, "experts", "reference", reference); + JsonObject selectedOperation = named(selectedExpert, "operations", "name", operationName); + int maxBytes = SemanticMediaInput.imageMaxBytes(selectedOperation); + if (localImage) { + value = SemanticMediaInput.readImage(selected, maxBytes); + request.put(inputKey, value); + } + SemanticMediaInput.validateImageSize(value, messageHeaders, maxBytes); + } + + private static JsonObject named(JsonObject metadata, String collection, String key, String name) { + if (name != null && metadata.getCollection(collection) != null) { + for (Object item : metadata.getCollection(collection)) { + if (item instanceof JsonObject value && name.equals(value.getString(key))) { + return value; + } + } + } + throw new IllegalArgumentException("Selected semantic " + collection + " unavailable in runtime metadata"); + } + private static void requireText(String value, String option) { if (value == null || value.isBlank()) { throw new IllegalArgumentException(option + " is required and must not be blank"); @@ -147,9 +192,6 @@ public class SemanticEvaluate extends SemanticActionCommand { } private static Object value(String value, boolean allowFile) { - if (allowFile && value.startsWith("image:")) { - return SemanticMediaInput.readImage(value); - } if (value.startsWith("json:")) { try { return JSON_VALUE.readValue(value.substring(5)); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/SemanticMediaInput.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/SemanticMediaInput.java index 8d332eb02188..d66cc5420cfb 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/SemanticMediaInput.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/SemanticMediaInput.java @@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.common; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.Base64; @@ -43,8 +44,55 @@ public final class SemanticMediaInput { return isMedia(input) ? (List<?>) ((Map<?, ?>) input).get(PARTS) : List.of(); } + public static boolean hasImages(Object input) { + return input instanceof String text && text.startsWith("data:image/") + || parts(input).stream().anyMatch(item -> item instanceof Map<?, ?> part && "image".equals(part.get("type"))); + } + + public static boolean hasImages(Object body, Object headers) { + return hasImages(body) || body instanceof String && hasImageContentType(headers); + } + + public static void validateImageSize(Object body, Object headers, int maxBytes) { + if (body instanceof String text && !text.startsWith("data:image/") && hasImageContentType(headers)) { + validateBase64Size(text, 0, maxBytes); + } else { + validateImageSize(body, maxBytes); + } + } + + private static boolean hasImageContentType(Object headers) { + return headers instanceof Map<?, ?> values && values.entrySet().stream() + .anyMatch(entry -> "Content-Type".equalsIgnoreCase(entry.getKey().toString()) + && entry.getValue() instanceof String type + && type.trim().toLowerCase(Locale.ROOT).startsWith("image/")); + } + + /** Check encoded images before sending a tooling request, without decoding their payloads. */ + public static void validateImageSize(Object input, int maxBytes) { + if (input instanceof String data && data.startsWith("data:image/")) { + int offset = imageDataOffset(data); + if (offset >= 0) { + validateBase64Size(data, offset, maxBytes); + } + } else { + for (Object item : parts(input)) { + if (item instanceof Map<?, ?> part && "image".equals(part.get("type"))) { + validateImageSize(part.get("data"), maxBytes); + } + } + } + } + + private static void validateBase64Size(String data, int offset, int maxBytes) { + int padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; + if ((data.length() - offset) * 3L / 4 - padding > maxBytes) { + throw new IllegalArgumentException("Image exceeds the expert maxImageBytes limit of " + maxBytes + " bytes"); + } + } + /** Read only an explicitly requested local image. This must run off the TUI render thread. */ - public static Object readImage(String value) { + public static Object readImage(String value, int maxBytes) { if (!value.startsWith("image:")) { return value; } @@ -56,14 +104,42 @@ public final class SemanticMediaInput { if (type == null) { throw new IllegalArgumentException("image: requires a local PNG, JPEG, GIF or WebP file"); } + if (maxBytes <= 0) { + throw new IllegalArgumentException("The selected expert must declare a positive maxImageBytes limit"); + } + String tooLarge = "Image file exceeds the expert maxImageBytes limit of " + maxBytes + " bytes"; try { - String data = "data:" + type + ";base64," + Base64.getEncoder().encodeToString(Files.readAllBytes(path)); + if (Files.size(path) > maxBytes) { + throw new IllegalArgumentException(tooLarge); + } + byte[] bytes; + try (InputStream input = Files.newInputStream(path)) { + // Bound the read too: a file can grow after the size check. + bytes = input.readNBytes(maxBytes); + if (input.read() != -1) { + throw new IllegalArgumentException(tooLarge); + } + } + String data = "data:" + type + ";base64," + Base64.getEncoder().encodeToString(bytes); return Map.of(PARTS, List.of(Map.of("type", "image", "data", data))); } catch (IOException e) { throw new IllegalArgumentException("Cannot read the selected image file", e); } } + /** Read the configured limit advertised by an image-capable expert operation. */ + public static int imageMaxBytes(Map<?, ?> operation) { + if (operation != null && operation.get("contract") instanceof Map<?, ?> contract + && contract.get("inputTypes") instanceof List<?> types && types.contains("image") + && contract.get("maxImageBytes") instanceof Number limit + && limit.longValue() > 0 && limit.longValue() <= Integer.MAX_VALUE + && limit.doubleValue() == limit.longValue()) { + return limit.intValue(); + } + throw new IllegalArgumentException( + "The selected expert must advertise image support and a positive maxImageBytes limit"); + } + /** Preserve metadata in terminal/JSON displays without printing image payloads. */ public static Object withoutImageData(Object value) { if (value instanceof Map<?, ?> map) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java index 5d01d33b5d1f..18b0a44951a4 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/SemanticCommandTest.java @@ -29,6 +29,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; @@ -62,11 +63,61 @@ class SemanticCommandTest { void imageFileBodyIsSentAsExplicitMedia() throws Exception { Path image = home.resolve("input.png"); Files.write(image, new byte[] { 1, 2, 3 }); - assertEquals(0, respond(new SemanticEvaluate(main()), request -> { + assertEquals(0, respondImage(new SemanticEvaluate(main()), request -> { assertEquals(Map.of("$semanticInput", List.of(Map.of("type", "image", "data", "data:image/png;base64,AQID"))), request.get("body")); return jsonUnchecked("{\"status\":\"success\",\"value\":true}"); - }, "--evaluation=inspect", "--body=image:" + image)); + }, 10_000_000, "--evaluation=inspect", "--body=image:" + image)); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void expertImageLimitRejectsBeforeSendingAnEvaluation(boolean named) throws Exception { + Path image = home.resolve("large.png"); + Files.write(image, new byte[] { 1, 2, 3 }); + String[] args = named + ? new String[] { "--evaluation=inspect", "--body=image:" + image, "--json" } + : new String[] { "--expert=inspector", "--operation=boolean", "--input=image:" + image, "--json" }; + assertEquals(2, respondImage(new SemanticEvaluate(main()), request -> { + throw new AssertionError("Oversized image must not reach evaluation"); + }, 2, args)); + assertError(2, "expert maxImageBytes limit of 2 bytes"); + } + + @ParameterizedTest + @ValueSource(strings = { + "data:image/png;base64,AQID", "AQID", + "json:{\"$semanticInput\":[{\"type\":\"image\",\"data\":\"data:image/png;base64,AQID\"}]}" }) + void expertLimitAlsoRejectsInlineImageBodies(String body) throws Exception { + assertEquals(2, respondImage(new SemanticEvaluate(main()), request -> { + throw new AssertionError("Oversized image must not reach evaluation"); + }, 2, "--evaluation=inspect", "--body=" + body, "--header=content-type=image/png", "--json")); + assertError(2, "expert maxImageBytes limit of 2 bytes"); + } + + @Test + void missingExpertImageLimitRejectsWithoutOpeningTheFile() throws Exception { + assertEquals(2, respondImage(new SemanticEvaluate(main()), request -> { + throw new AssertionError("Unbounded image must not reach evaluation"); + }, 0, "--expert=inspector", "--operation=boolean", "--input=image:" + home.resolve("missing.png"), "--json")); + assertError(2, "positive maxImageBytes"); + } + + private int respondImage( + SemanticActionCommand command, Function<JsonObject, JsonObject> response, int maxBytes, + String... options) + throws Exception { + return respond(command, request -> { + if ("semantic-metadata".equals(request.getString("action"))) { + return new JsonObject( + Map.of("experts", List.of(Map.of("reference", "inspector", "operations", List.of( + Map.of("name", "boolean", "contract", + Map.of("inputTypes", List.of("image"), "maxImageBytes", maxBytes))))), + "evaluations", + List.of(Map.of("name", "inspect", "expert", "inspector", "operation", "boolean")))); + } + return response.apply(request); + }, options); } @ParameterizedTest @@ -834,17 +885,29 @@ class SemanticCommandTest { } private int respond(IntSupplier call, Function<JsonObject, JsonObject> response) throws Exception { + var completed = new AtomicBoolean(); var responder = CompletableFuture.runAsync(() -> { try { - await().atMost(5, TimeUnit.SECONDS).until(() -> !actionFiles().isEmpty()); - Path request = actionFiles().get(0); - await().atMost(5, TimeUnit.SECONDS).until(() -> Files.size(request) > 0); - reply(request, response.apply(json(Files.readString(request)))); + while (!completed.get()) { + await().atMost(5, TimeUnit.SECONDS).until(() -> completed.get() || !actionFiles().isEmpty()); + if (completed.get()) { + break; + } + Path request = actionFiles().get(0); + await().atMost(5, TimeUnit.SECONDS).until(() -> Files.size(request) > 0); + reply(request, response.apply(json(Files.readString(request)))); + await().atMost(5, TimeUnit.SECONDS).until(() -> !Files.exists(request)); + } } catch (Exception e) { throw new RuntimeException(e); } }, executor); - int result = call.getAsInt(); + int result; + try { + result = call.getAsInt(); + } finally { + completed.set(true); + } responder.get(5, TimeUnit.SECONDS); assertTrue(actionFiles().isEmpty()); return result; diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/SemanticMediaInputTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/SemanticMediaInputTest.java index 68cbd070f22d..6217cb3a13cf 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/SemanticMediaInputTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/SemanticMediaInputTest.java @@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.common; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Base64; import java.util.List; @@ -31,7 +32,10 @@ import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; class SemanticMediaInputTest { @TempDir @@ -43,7 +47,7 @@ class SemanticMediaInputTest { image.setRGB(0, 0, 0xff0000); Path path = directory.resolve("sample.png"); ImageIO.write(image, "png", path.toFile()); - Object input = SemanticMediaInput.readImage("image:" + path); + Object input = SemanticMediaInput.readImage("image:" + path, 10_000_000); assertTrue(SemanticMediaInput.isMedia(input)); var description = SemanticMediaInput.withoutImageData(input).toString(); assertTrue(description.contains("image/png")); @@ -87,10 +91,46 @@ class SemanticMediaInputTest { assertEquals(List.of(0xffffff, 0x7f7f7f), preview.pixels().get(0)); } + @Test + void expertLimitIncludesTheBoundaryAndRejectsLargerFiles() throws Exception { + Path path = directory.resolve("sample.png"); + Files.write(path, new byte[] { 1, 2, 3 }); + assertTrue(SemanticMediaInput.isMedia(SemanticMediaInput.readImage("image:" + path, 3))); + var failure = assertThrows(IllegalArgumentException.class, () -> SemanticMediaInput.readImage("image:" + path, 2)); + assertTrue(failure.getMessage().contains("expert maxImageBytes limit of 2 bytes")); + assertThrows(IllegalArgumentException.class, () -> SemanticMediaInput.readImage("image:" + path, 0)); + } + + @Test + void readRemainsBoundedWhenTheFileGrowsAfterTheSizeCheck() throws Exception { + Path path = directory.resolve("growing.png"); + Files.write(path, new byte[] { 1, 2, 3, 4 }); + try (var files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.size(path)).thenReturn(3L); + assertThrows(IllegalArgumentException.class, () -> SemanticMediaInput.readImage("image:" + path, 3)); + } + } + + @Test + void usesOnlyAdvertisedImageLimitsAndChecksEncodedImages() { + assertEquals(3, SemanticMediaInput + .imageMaxBytes(Map.of("contract", Map.of("inputTypes", List.of("image"), "maxImageBytes", 3)))); + for (Object limit : List.of(0, -1, 1.5, Long.MAX_VALUE, "3")) { + assertThrows(IllegalArgumentException.class, () -> SemanticMediaInput.imageMaxBytes( + Map.of("contract", Map.of("inputTypes", List.of("image"), "maxImageBytes", limit)))); + } + assertThrows(IllegalArgumentException.class, () -> SemanticMediaInput.imageMaxBytes(Map.of())); + Object input = Map.of("$semanticInput", List.of(Map.of("type", "image", "data", "data:image/png;base64,AQID"))); + assertTrue(SemanticMediaInput.hasImages(input)); + SemanticMediaInput.validateImageSize(input, 3); + assertThrows(IllegalArgumentException.class, () -> SemanticMediaInput.validateImageSize(input, 2)); + } + @Test void ordinaryStateAndTextKeepTheirMeaning() { Object input = Map.of("type", "image", "data", "application state"); assertEquals(input, SemanticMediaInput.withoutImageData(input)); - assertEquals("https://example.com/picture.png", SemanticMediaInput.readImage("https://example.com/picture.png")); + assertEquals("https://example.com/picture.png", + SemanticMediaInput.readImage("https://example.com/picture.png", 10_000_000)); } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java index 49ab089ffc55..06a01d0f5581 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticDetails.java @@ -183,6 +183,9 @@ final class SemanticDetails { result.add(Line.from(Span.styled("Operation contract", Theme.label()))); result.add(Line.empty()); add(result, "Input", inputTypes(op)); + if (contract(op).containsKey("maxImageBytes")) { + add(result, "Maximum image bytes", text(contract(op), "maxImageBytes")); + } add(result, "Accepts", text(contract(op), "inputRequirements")); result.add(Line.empty()); result.addAll(result(op, definition.get("parameters"))); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticEvaluationPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticEvaluationPopup.java index 87314cd114e3..89f98f33a9a4 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticEvaluationPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticEvaluationPopup.java @@ -204,8 +204,13 @@ final class SemanticEvaluationPopup { pending = CompletableFuture .supplyAsync(() -> { JsonObject prepared = new JsonObject(request); - if (prepared.get("body") instanceof String text) { - prepared.put("body", SemanticMediaInput.readImage(text)); + if (prepared.get("body") instanceof String text && text.startsWith("image:")) { + prepared.put("body", + SemanticMediaInput.readImage(text, SemanticMediaInput.imageMaxBytes(submittedOperation))); + } + if (SemanticMediaInput.hasImages(prepared.get("body"), prepared.get("headers"))) { + SemanticMediaInput.validateImageSize(prepared.get("body"), prepared.get("headers"), + SemanticMediaInput.imageMaxBytes(submittedOperation)); } return ctx.executeIndependentAction(pid, prepared, 60000); }, ctx.backgroundExecutor) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java index 54c760bd48dd..45cb462cc41b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticPlayground.java @@ -377,8 +377,13 @@ final class SemanticPlayground { pending = CompletableFuture .supplyAsync(() -> { JsonObject prepared = new JsonObject(request); - if (readLocalImage && prepared.get("input") instanceof String text) { - prepared.put("input", SemanticMediaInput.readImage(text)); + if (readLocalImage && prepared.get("input") instanceof String text && text.startsWith("image:")) { + prepared.put("input", + SemanticMediaInput.readImage(text, SemanticMediaInput.imageMaxBytes(operation))); + } + if (SemanticMediaInput.hasImages(prepared.get("input"))) { + SemanticMediaInput.validateImageSize(prepared.get("input"), + SemanticMediaInput.imageMaxBytes(operation)); } return ctx.executeIndependentAction(pid, prepared, 60000); }, ctx.backgroundExecutor) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticImageViewTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticImageViewTest.java index a293f8df8b44..e8a019beec6b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticImageViewTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SemanticImageViewTest.java @@ -81,7 +81,8 @@ class SemanticImageViewTest { runtime.selectedPid = "image"; var operation = new JsonObject( Map.of("name", "boolean", "contract", new JsonObject( - Map.of("inputTypes", List.of("text", "image"), "parameters", List.of())))); + Map.of("inputTypes", List.of("text", "image"), "maxImageBytes", 10_000_000, "parameters", + List.of())))); var playground = new SemanticPlayground(runtime, "image", "expert", operation); var input = Map.of("$semanticInput", List.of(Map.of("type", "image", "data", "data:image/png;base64,AQID"))); playground.loadAuditInput(new JsonObject(Map.of("eventId", "record", "input", input))); @@ -116,7 +117,8 @@ class SemanticImageViewTest { runtime.selectedPid = "image"; var operation = new JsonObject( Map.of("name", "boolean", "contract", new JsonObject( - Map.of("inputTypes", List.of("text", "image"), "parameters", List.of())))); + Map.of("inputTypes", List.of("text", "image"), "maxImageBytes", 10_000_000, "parameters", + List.of())))); var playground = new SemanticPlayground(runtime, "image", "expert", operation); String input = "image:" + file; playground.loadAuditInput(new JsonObject(Map.of("eventId", "record", "input", input))); @@ -134,4 +136,47 @@ class SemanticImageViewTest { runtime.backgroundExecutor.shutdownNow(); } } + + @Test + void expertLimitRejectsLocalAndInlineImagesBeforeSubmitting(@TempDir Path directory) throws Exception { + Path file = directory.resolve("large.png"); + Files.write(file, new byte[] { 1, 2, 3 }); + var runtime = new MonitorContext(new AtomicReference<>(List.of()), new AtomicReference<>(List.of())) { + final List<JsonObject> calls = new CopyOnWriteArrayList<>(); + + @Override + JsonObject executeIndependentAction(String pid, JsonObject request, long timeout) { + calls.add(request); + return new JsonObject(Map.of("status", "success")); + } + }; + try { + var integration = new IntegrationInfo(); + integration.pid = "image"; + runtime.data.set(List.of(integration)); + runtime.selectedPid = "image"; + var operation = new JsonObject( + Map.of("name", "boolean", "contract", new JsonObject( + Map.of("inputTypes", List.of("text", "image"), "parameters", List.of(), "maxImageBytes", 2)))); + for (String input : List.of("image:" + file, "data:image/png;base64,AQID")) { + var playground = new SemanticPlayground(runtime, "image", "expert", operation); + assertThat(playground.setInputValue("input", input)).isTrue(); + playground.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.CTRL)); + await().atMost(5, TimeUnit.SECONDS).untilAsserted( + () -> assertThat(playground.snapshot().toJson()).contains("expert maxImageBytes limit of 2 bytes")); + assertThat(runtime.calls).isEmpty(); + } + for (String body : List.of("image:" + file, "data:image/png;base64,AQID", "AQID")) { + var popup = new SemanticEvaluationPopup(runtime, "image", new JsonObject(Map.of("name", "inspect")), operation); + assertThat(popup.setInputValue("sample", new JsonObject( + Map.of("body", body, "headers", Map.of("Content-Type", "image/png"))).toJson())).isTrue(); + popup.handleKeyEvent(KeyEvent.ofChar('r', KeyModifiers.CTRL)); + await().atMost(5, TimeUnit.SECONDS).untilAsserted( + () -> assertThat(popup.snapshot().toJson()).contains("expert maxImageBytes limit of 2 bytes")); + assertThat(runtime.calls).isEmpty(); + } + } finally { + runtime.backgroundExecutor.shutdownNow(); + } + } }
