This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 732191e16f76 CAMEL-24224: camel-aws-bedrock - capture streaming
metadata for Claude/Nova and accept inference profile ids (#24982)
732191e16f76 is described below
commit 732191e16f76356c2e0c9c6be39ec66da0c6f1e2
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Jul 21 18:03:28 2026 +0200
CAMEL-24224: camel-aws-bedrock - capture streaming metadata for Claude/Nova
and accept inference profile ids (#24982)
Inspect every streaming chunk for metadata instead of gating on the final
chunk; resolve inference-profile ids/ARNs and fall back to family-prefix
dispatch.
Co-authored-by: Claude Fable 5 <[email protected]>
---
.../aws2/bedrock/runtime/BedrockProducer.java | 71 +++++++++++++++++++-
.../runtime/stream/BedrockStreamHandler.java | 47 +++++++-------
.../runtime/BedrockProducerModelIdTest.java | 55 ++++++++++++++++
.../stream/BedrockStreamHandlerMetadataTest.java | 75 ++++++++++++++++++++++
4 files changed, 223 insertions(+), 25 deletions(-)
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 75e0cacb9c41..05ffe8fe0f6f 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
@@ -63,6 +63,11 @@ import
software.amazon.awssdk.services.bedrockruntime.model.ToolConfiguration;
public class BedrockProducer extends DefaultProducer {
private static final Logger LOG =
LoggerFactory.getLogger(BedrockProducer.class);
+
+ /**
+ * Region prefixes used by Bedrock cross-region inference profile ids,
e.g. {@code us.anthropic.claude-...}.
+ */
+ private static final List<String> INFERENCE_PROFILE_REGION_PREFIXES =
List.of("us.", "eu.", "apac.", "us-gov.");
private transient String bedrockProducerToString;
public BedrockProducer(Endpoint endpoint) {
@@ -282,7 +287,7 @@ public class BedrockProducer extends DefaultProducer {
}
protected void setResponseText(InvokeModelResponse result, Message
message) {
- String modelId = getConfiguration().getModelId();
+ String modelId =
resolveFoundationModelId(getConfiguration().getModelId());
switch (modelId) {
// Amazon Titan Models
case "amazon.titan-text-express-v1":
@@ -425,7 +430,71 @@ public class BedrockProducer extends DefaultProducer {
+ " is a video/speech
generation model and cannot be used with the invokeTextModel operation.");
default:
+ setResponseTextByModelFamily(modelId, result, message);
+ }
+ }
+
+ /**
+ * Resolves the underlying foundation-model id for a model reference.
Bedrock also accepts cross-region inference
+ * profile ids (for example {@code
us.anthropic.claude-3-5-sonnet-20241022-v2:0}) and inference-profile or
+ * provisioned-throughput ARNs in place of the plain model id. They all
address the same foundation model, so they
+ * must be handled the same way.
+ */
+ protected static String resolveFoundationModelId(String modelId) {
+ if (modelId == null) {
+ return null;
+ }
+ String resolved = modelId;
+ // an ARN carries the profile or model id in its last path segment
+ if (resolved.startsWith("arn:")) {
+ int slash = resolved.lastIndexOf('/');
+ if (slash >= 0 && slash < resolved.length() - 1) {
+ resolved = resolved.substring(slash + 1);
+ }
+ }
+ for (String prefix : INFERENCE_PROFILE_REGION_PREFIXES) {
+ if (resolved.startsWith(prefix)) {
+ return resolved.substring(prefix.length());
+ }
+ }
+ return resolved;
+ }
+
+ /**
+ * Fallback for model ids that are not listed explicitly, typically a
model released after this component was built.
+ * Dispatching on the vendor/family prefix lets a new model of a known
family work instead of failing, since the
+ * response format is defined by the family rather than the individual
model.
+ */
+ private void setResponseTextByModelFamily(String modelId,
InvokeModelResponse result, Message message) {
+ if (modelId == null) {
+ throw new IllegalStateException("Model id must be specified");
+ }
+ // families that do not produce a text completion must keep failing
rather than being parsed as text
+ if (modelId.startsWith("stability.") ||
modelId.contains("image-generator") || modelId.contains("-canvas")
+ || modelId.contains("embed") || modelId.contains("rerank") ||
modelId.contains("-reel")
+ || modelId.contains("-sonic")) {
+ throw new IllegalArgumentException(
+ "Model " + modelId
+ + " is not a text generation
model and cannot be used with the invokeTextModel operation.");
+ }
+ try {
+ if (modelId.startsWith("amazon.titan-text")) {
+ setTitanText(result, message);
+ } else if (modelId.startsWith("anthropic.claude") ||
modelId.startsWith("amazon.nova")) {
+ setAnthropicV3Text(result, message);
+ } else if (modelId.startsWith("meta.llama")) {
+ setLlamaText(result, message);
+ } else if (modelId.startsWith("mistral.")) {
+ setMistralText(result, message);
+ } else if (modelId.startsWith("cohere.command")) {
+ setCohereText(result, message);
+ } else if (modelId.startsWith("ai21.")) {
+ setAi21Text(result, message);
+ } else {
throw new IllegalStateException("Unexpected model: " +
modelId);
+ }
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException(e);
}
}
diff --git
a/components/camel-aws/camel-aws-bedrock/src/main/java/org/apache/camel/component/aws2/bedrock/runtime/stream/BedrockStreamHandler.java
b/components/camel-aws/camel-aws-bedrock/src/main/java/org/apache/camel/component/aws2/bedrock/runtime/stream/BedrockStreamHandler.java
index cf0feaafdbd5..5b6bdc14ff39 100644
---
a/components/camel-aws/camel-aws-bedrock/src/main/java/org/apache/camel/component/aws2/bedrock/runtime/stream/BedrockStreamHandler.java
+++
b/components/camel-aws/camel-aws-bedrock/src/main/java/org/apache/camel/component/aws2/bedrock/runtime/stream/BedrockStreamHandler.java
@@ -36,6 +36,27 @@ public final class BedrockStreamHandler {
// Utility class
}
+ /**
+ * Records the completion reason and token count carried by a streaming
chunk, if any.
+ * <p>
+ * Metadata is not always carried by the final chunk: Claude and Nova
report {@code stop_reason} and {@code usage}
+ * on the {@code message_delta} chunk, which precedes the final {@code
message_stop}, while Titan, Llama, Mistral
+ * and Cohere report them on their final chunk. Every chunk is therefore
inspected and whatever is present is kept;
+ * the parsers return {@code null} for chunks that do not carry the values.
+ */
+ static void applyChunkMetadata(StreamResponseParser parser, String
chunkJson, StreamMetadata metadata)
+ throws JsonProcessingException {
+ String completionReason = parser.extractCompletionReason(chunkJson);
+ if (ObjectHelper.isNotEmpty(completionReason)) {
+ metadata.setCompletionReason(completionReason);
+ }
+
+ Integer tokenCount = parser.extractTokenCount(chunkJson);
+ if (ObjectHelper.isNotEmpty(tokenCount)) {
+ metadata.setTokenCount(tokenCount);
+ }
+ }
+
/**
* Create a response handler for complete mode (accumulates all chunks)
*
@@ -62,18 +83,7 @@ public final class BedrockStreamHandler {
fullText.append(text);
}
- // Extract metadata from final chunk
- if (parser.isFinalChunk(chunkJson)) {
- String completionReason =
parser.extractCompletionReason(chunkJson);
- if
(ObjectHelper.isNotEmpty(completionReason)) {
-
metadata.setCompletionReason(completionReason);
- }
-
- Integer tokenCount =
parser.extractTokenCount(chunkJson);
- if (ObjectHelper.isNotEmpty(tokenCount)) {
- metadata.setTokenCount(tokenCount);
- }
- }
+ applyChunkMetadata(parser, chunkJson,
metadata);
chunkCount[0]++;
} catch (JsonProcessingException e) {
LOG.warn("Error parsing streaming chunk: {}",
e.getMessage(), e);
@@ -119,18 +129,7 @@ public final class BedrockStreamHandler {
}
}
- // Extract metadata from final chunk
- if (parser.isFinalChunk(chunkJson)) {
- String completionReason =
parser.extractCompletionReason(chunkJson);
- if
(ObjectHelper.isNotEmpty(completionReason)) {
-
metadata.setCompletionReason(completionReason);
- }
-
- Integer tokenCount =
parser.extractTokenCount(chunkJson);
- if (ObjectHelper.isNotEmpty(tokenCount)) {
- metadata.setTokenCount(tokenCount);
- }
- }
+ applyChunkMetadata(parser, chunkJson,
metadata);
chunkCount[0]++;
} catch (JsonProcessingException e) {
LOG.warn("Error parsing streaming chunk: {}",
e.getMessage(), e);
diff --git
a/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockProducerModelIdTest.java
b/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockProducerModelIdTest.java
new file mode 100644
index 000000000000..5d5eb2855bca
--- /dev/null
+++
b/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/BedrockProducerModelIdTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Bedrock accepts cross-region inference profile ids and inference-profile
ARNs in place of a plain foundation-model
+ * id. They must resolve to the same foundation model so the response is
parsed with the right model family.
+ */
+class BedrockProducerModelIdTest {
+
+ private static final String CLAUDE =
"anthropic.claude-3-5-sonnet-20241022-v2:0";
+
+ @Test
+ void plainModelIdIsUnchanged() {
+ assertEquals(CLAUDE, BedrockProducer.resolveFoundationModelId(CLAUDE));
+ }
+
+ @Test
+ void crossRegionInferenceProfileIdsResolveToTheFoundationModel() {
+ assertEquals(CLAUDE, BedrockProducer.resolveFoundationModelId("us." +
CLAUDE));
+ assertEquals(CLAUDE, BedrockProducer.resolveFoundationModelId("eu." +
CLAUDE));
+ assertEquals(CLAUDE, BedrockProducer.resolveFoundationModelId("apac."
+ CLAUDE));
+ assertEquals(CLAUDE,
BedrockProducer.resolveFoundationModelId("us-gov." + CLAUDE));
+ }
+
+ @Test
+ void inferenceProfileArnResolvesToTheFoundationModel() {
+ String arn =
"arn:aws:bedrock:eu-west-1:123456789012:inference-profile/eu." + CLAUDE;
+ assertEquals(CLAUDE, BedrockProducer.resolveFoundationModelId(arn));
+ }
+
+ @Test
+ void nullModelIdIsTolerated() {
+ assertNull(BedrockProducer.resolveFoundationModelId(null));
+ }
+}
diff --git
a/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/stream/BedrockStreamHandlerMetadataTest.java
b/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/stream/BedrockStreamHandlerMetadataTest.java
new file mode 100644
index 000000000000..2afd7672b004
--- /dev/null
+++
b/components/camel-aws/camel-aws-bedrock/src/test/java/org/apache/camel/component/aws2/bedrock/runtime/stream/BedrockStreamHandlerMetadataTest.java
@@ -0,0 +1,75 @@
+/*
+ * 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.stream;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * Claude and Nova report the completion reason and token usage on the {@code
message_delta} chunk, which is not the
+ * final chunk ({@code message_stop}). Metadata extraction must therefore not
be gated on the final chunk.
+ */
+class BedrockStreamHandlerMetadataTest {
+
+ private static final String MESSAGE_DELTA
+ =
"{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":150}}";
+ private static final String MESSAGE_STOP = "{\"type\":\"message_stop\"}";
+ private static final String CONTENT_DELTA
+ =
"{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}";
+
+ @Test
+ void claudeMetadataIsCapturedFromTheNonFinalMessageDeltaChunk() throws
Exception {
+ ClaudeStreamParser parser = new ClaudeStreamParser();
+ BedrockStreamHandler.StreamMetadata metadata = new
BedrockStreamHandler.StreamMetadata();
+
+ // the chunk carrying the metadata is explicitly not the final chunk
+ assertFalse(parser.isFinalChunk(MESSAGE_DELTA));
+
+ BedrockStreamHandler.applyChunkMetadata(parser, MESSAGE_DELTA,
metadata);
+
+ assertEquals("end_turn", metadata.getCompletionReason());
+ assertEquals(150, metadata.getTokenCount());
+ }
+
+ @Test
+ void chunksWithoutMetadataLeaveEarlierValuesIntact() throws Exception {
+ ClaudeStreamParser parser = new ClaudeStreamParser();
+ BedrockStreamHandler.StreamMetadata metadata = new
BedrockStreamHandler.StreamMetadata();
+
+ // a realistic ordering: text chunk, then the metadata-bearing delta,
then the final stop chunk
+ BedrockStreamHandler.applyChunkMetadata(parser, CONTENT_DELTA,
metadata);
+ BedrockStreamHandler.applyChunkMetadata(parser, MESSAGE_DELTA,
metadata);
+ BedrockStreamHandler.applyChunkMetadata(parser, MESSAGE_STOP,
metadata);
+
+ assertEquals("end_turn", metadata.getCompletionReason());
+ assertEquals(150, metadata.getTokenCount());
+ }
+
+ @Test
+ void titanMetadataOnTheFinalChunkStillWorks() throws Exception {
+ TitanStreamParser parser = new TitanStreamParser();
+ BedrockStreamHandler.StreamMetadata metadata = new
BedrockStreamHandler.StreamMetadata();
+ String finalChunk =
"{\"outputText\":\"done\",\"completionReason\":\"FINISH\",\"totalOutputTextTokenCount\":42}";
+
+ BedrockStreamHandler.applyChunkMetadata(parser, finalChunk, metadata);
+
+ assertEquals("FINISH", metadata.getCompletionReason());
+ assertEquals(42, metadata.getTokenCount());
+ }
+}