diff --git a/components/camel-aws/camel-aws-bedrock/src/main/docs/aws-bedrock-component.adoc b/components/camel-aws/camel-aws-bedrock/src/main/docs/aws-bedrock-component.adoc index 98a778ca7f7ee..14af8e96e6c56 100644 --- a/components/camel-aws/camel-aws-bedrock/src/main/docs/aws-bedrock-component.adoc +++ b/components/camel-aws/camel-aws-bedrock/src/main/docs/aws-bedrock-component.adoc @@ -83,6 +83,12 @@ Camel-AWS Bedrock component provides the following operation on the producer sid - converseStream - applyGuardrail +The operation must match the model type. Text-generation models are used with `invokeTextModel`, image-generation +models (for example `amazon.titan-image-generator-v1`, `amazon.nova-canvas-v1:0`, the `stability.*` models) with +`invokeImageModel`, and embedding models (for example `amazon.titan-embed-text-v1`, `cohere.embed-english-v3`) with +`invokeEmbeddingsModel`. Invoking `invokeTextModel` with an image, embedding, rerank or video/speech model fails fast +with an `IllegalArgumentException` that names the model and points to the operation to use instead. + === Streaming Support The streaming operations (`invokeTextModelStreaming`, `invokeImageModelStreaming`, `invokeEmbeddingsModelStreaming`) enable real-time streaming of model responses, providing lower latency and better user experience for interactive applications. diff --git a/components/camel-aws/camel-aws-bedrock/src/main/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockProducer.java b/components/camel-aws/camel-aws-bedrock/src/main/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockProducer.java index 1c40bc6cc7309..d5e2ffa7f5a28 100644 --- a/components/camel-aws/camel-aws-bedrock/src/main/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockProducer.java +++ b/components/camel-aws/camel-aws-bedrock/src/main/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockProducer.java @@ -379,6 +379,42 @@ protected void setResponseText(InvokeModelResponse result, Message message) { } break; + // Image generation models — use the invokeImageModel operation instead + case "amazon.titan-image-generator-v1": + case "amazon.titan-image-generator-v2:0": + case "amazon.nova-canvas-v1:0": + case "stability.sd3-5-large-v1:0": + case "stability.stable-image-control-sketch-v1:0": + case "stability.stable-image-control-structure-v1:0": + case "stability.stable-image-core-v1:1": + throw new IllegalArgumentException( + "Model " + modelId + + " is an image generation model and cannot be used with the invokeTextModel operation. Use the invokeImageModel operation instead."); + + // Embedding models — use the invokeEmbeddingsModel operation instead + case "amazon.titan-embed-text-v1": + case "amazon.titan-embed-image-v1": + case "cohere.embed-english-v3": + case "cohere.embed-multilingual-v3": + throw new IllegalArgumentException( + "Model " + modelId + + " is an embedding model and cannot be used with the invokeTextModel operation. Use the invokeEmbeddingsModel operation instead."); + + // Rerank models — not supported by the invokeTextModel operation + case "amazon.rerank-v1:0": + case "cohere.rerank-v3-5:0": + throw new IllegalArgumentException( + "Model " + modelId + + " is a rerank model and cannot be used with the invokeTextModel operation."); + + // Video and speech models — not supported by the invokeTextModel operation + case "amazon.nova-reel-v1:0": + case "amazon.nova-reel-v1:1": + case "amazon.nova-sonic-v1:0": + throw new IllegalArgumentException( + "Model " + modelId + + " is a video/speech generation model and cannot be used with the invokeTextModel operation."); + default: throw new IllegalStateException("Unexpected model: " + modelId); } diff --git a/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockInvokeTextModelUnsupportedModelTest.java b/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockInvokeTextModelUnsupportedModelTest.java new file mode 100644 index 0000000000000..63e0bfcf34bfc --- /dev/null +++ b/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockInvokeTextModelUnsupportedModelTest.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.aws2.bedrock.runtime; + +import java.util.stream.Stream; + +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.component.aws2.bedrock.BedrockModels; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.support.SimpleRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; +import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; + +/** + * Unit tests asserting that the {@code invokeTextModel} operation rejects image, embedding, rerank and video/speech + * models with a clear, actionable {@link IllegalArgumentException} instead of the confusing + * {@code IllegalStateException("Unexpected model: ...")} that these models — all advertised in + * {@code BedrockConfiguration.modelId}'s {@code @UriParam(enums=...)} — previously produced (CAMEL-23463). Uses a + * mocked {@link BedrockRuntimeClient}, so no AWS credentials or network access are required. + */ +@ExtendWith(MockitoExtension.class) +public class BedrockInvokeTextModelUnsupportedModelTest { + + @Mock + private BedrockRuntimeClient client; + + private CamelContext camelContext; + private ProducerTemplate template; + + @BeforeEach + public void setup() { + lenient().when(client.invokeModel(any(InvokeModelRequest.class))) + .thenReturn(InvokeModelResponse.builder().body(SdkBytes.fromUtf8String("{}")).build()); + + SimpleRegistry registry = new SimpleRegistry(); + registry.bind("bedrockClient", client); + camelContext = new DefaultCamelContext(registry); + camelContext.start(); + template = camelContext.createProducerTemplate(); + } + + @AfterEach + public void teardown() { + if (template != null) { + template.stop(); + } + if (camelContext != null) { + camelContext.stop(); + } + } + + static Stream nonTextModels() { + return Stream.of( + // Image generation models -> invokeImageModel + Arguments.of(BedrockModels.TITAN_IMAGE_GENERATOR_V1.model, "invokeImageModel"), + Arguments.of(BedrockModels.TITAN_IMAGE_GENERATOR_V2.model, "invokeImageModel"), + Arguments.of(BedrockModels.NOVA_CANVAS_V1.model, "invokeImageModel"), + Arguments.of(BedrockModels.STABLE_DIFFUSION_3_5_LARGE.model, "invokeImageModel"), + Arguments.of(BedrockModels.STABLE_IMAGE_CONTROL_SKETCH.model, "invokeImageModel"), + Arguments.of(BedrockModels.STABLE_IMAGE_CONTROL_STRUCTURE.model, "invokeImageModel"), + Arguments.of(BedrockModels.STABLE_IMAGE_CORE.model, "invokeImageModel"), + // Embedding models -> invokeEmbeddingsModel + Arguments.of(BedrockModels.TITAN_EMBEDDINGS_G1.model, "invokeEmbeddingsModel"), + Arguments.of(BedrockModels.TITAN_MULTIMODAL_EMBEDDINGS_G1.model, "invokeEmbeddingsModel"), + Arguments.of(BedrockModels.COHERE_EMBED_ENGLISH_V3.model, "invokeEmbeddingsModel"), + Arguments.of(BedrockModels.COHERE_EMBED_MULTILINGUAL_V3.model, "invokeEmbeddingsModel"), + // Rerank models -> no dedicated operation, just reject clearly + Arguments.of(BedrockModels.RERANK_V1.model, null), + Arguments.of(BedrockModels.COHERE_RERANK_V3_5.model, null), + // Video / speech models -> no dedicated operation, just reject clearly + Arguments.of(BedrockModels.NOVA_REEL_V1.model, null), + Arguments.of(BedrockModels.NOVA_REEL_V1_1.model, null), + Arguments.of(BedrockModels.NOVA_SONIC_V1.model, null)); + } + + @ParameterizedTest + @MethodSource("nonTextModels") + public void invokeTextModelRejectsNonTextModelWithActionableMessage(String modelId, String expectedOperationHint) { + Exchange exchange = template.send(endpointUri(modelId), ex -> { + ex.getMessage().setHeader(BedrockConstants.MODEL_CONTENT_TYPE, "application/json"); + ex.getMessage().setHeader(BedrockConstants.MODEL_ACCEPT_CONTENT_TYPE, "application/json"); + ex.getMessage().setBody("{}"); + }); + + Throwable cause = exchange.getException(); + assertNotNull(cause, "invokeTextModel should fail for non-text model " + modelId); + assertInstanceOf(IllegalArgumentException.class, cause, + "Expected a clear IllegalArgumentException for " + modelId + " but was: " + cause); + assertTrue(cause.getMessage().contains(modelId), + "Error message should name the offending model — was: " + cause.getMessage()); + assertTrue(cause.getMessage().contains("invokeTextModel"), + "Error message should mention the invokeTextModel operation — was: " + cause.getMessage()); + if (expectedOperationHint != null) { + assertTrue(cause.getMessage().contains(expectedOperationHint), + "Error message should point to the " + expectedOperationHint + " operation — was: " + cause.getMessage()); + } + } + + @Test + public void invokeTextModelStillWorksForTextModel() { + Exchange exchange = template.send(endpointUri(BedrockModels.TITAN_TEXT_EXPRESS_V1.model), ex -> { + ex.getMessage().setHeader(BedrockConstants.MODEL_CONTENT_TYPE, "application/json"); + ex.getMessage().setHeader(BedrockConstants.MODEL_ACCEPT_CONTENT_TYPE, "application/json"); + ex.getMessage().setBody("{}"); + }); + + assertNull(exchange.getException(), "A text model must not be rejected by invokeTextModel"); + assertEquals("{}", exchange.getMessage().getBody(String.class), + "Titan text response should be returned as-is"); + } + + private static String endpointUri(String modelId) { + return "aws-bedrock://label" + + "?bedrockRuntimeClient=#bedrockClient" + + "&operation=invokeTextModel" + + "&modelId=" + modelId + + "®ion=us-east-1" + + "&accessKey=unused" + + "&secretKey=unused"; + } +}