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 9084768ea5af CAMEL-24223: Add camel_route_cost_estimate MCP tool
9084768ea5af is described below
commit 9084768ea5af62f908cd8fcfde78492118573589
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Jul 27 11:02:46 2026 +0200
CAMEL-24223: Add camel_route_cost_estimate MCP tool
New MCP tool that estimates API costs for a Camel route based on component
usage. Covers Bedrock (runtime and agent-runtime), Textract, S3, SQS, SNS,
Kinesis, OpenAI, LangChain4j (chat and embeddings), and Docling. Provides
per-execution cost breakdown, hourly/daily/monthly projections, and
optimization tips.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---
.../core/commands/mcp/RouteCostEstimateTools.java | 357 +++++++++++++++++++++
.../commands/mcp/RouteCostEstimateToolsTest.java | 180 +++++++++++
2 files changed, 537 insertions(+)
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateTools.java
new file mode 100644
index 000000000000..a1aa84fb5eca
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateTools.java
@@ -0,0 +1,357 @@
+/*
+ * 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.dsl.jbang.core.commands.mcp;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import io.quarkiverse.mcp.server.Tool;
+import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkiverse.mcp.server.ToolCallException;
+
+/**
+ * MCP Tool for estimating API costs of a Camel route based on its component
usage.
+ * <p>
+ * Particularly useful for AI pipelines where Bedrock, Textract, and S3 calls
have per-unit pricing. Provides
+ * per-execution cost estimates and projections at a given throughput.
+ * <p>
+ * Cost data is approximate and based on published AWS pricing as of 2025.
Actual costs depend on model, region, and
+ * volume tiers.
+ */
+@ApplicationScoped
+public class RouteCostEstimateTools {
+
+ private static final Pattern YAML_SCHEME_PATTERN = Pattern.compile(
+ "(?:uri:\\s*[\"']?|from:[ \\t]+[\"']?|to:[ \\t]+[\"']?|toD:[
\\t]+[\"']?)"
+ +
"([a-zA-Z][a-zA-Z0-9+.-]*):(?://)?",
+ Pattern.MULTILINE);
+
+ private static final Pattern XML_SCHEME_PATTERN = Pattern.compile(
+ "(?:<from|<to|<toD)\\s+uri=[\"']([a-zA-Z][a-zA-Z0-9+.-]*):",
+ Pattern.CASE_INSENSITIVE);
+
+ private static final Map<String, ComponentCostProfile> COST_PROFILES =
buildCostProfiles();
+
+ @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
+ description = "Estimate API costs for a Camel route based on its
component usage. "
+ + "Analyzes a YAML or XML route definition and
identifies components with "
+ + "pay-per-use pricing. Currently covers: Bedrock
(runtime and agent-runtime), "
+ + "Textract, S3, SQS, SNS, Kinesis, OpenAI,
LangChain4j (chat and embeddings), "
+ + "and Docling (free/self-hosted). "
+ + "Returns per-execution cost estimate and monthly
projection at a given throughput. "
+ + "Cost data is approximate based on published AWS
pricing (2025-Q2).")
+ public CostEstimateResult camel_route_cost_estimate(
+ @ToolArg(description = "The Camel route definition (YAML or XML)")
String route,
+ @ToolArg(description = "Expected messages per hour for cost
projection (default: 100)") Integer messagesPerHour,
+ @ToolArg(description = "Average document pages per message for
Textract/Docling (default: 5)") Integer avgPages,
+ @ToolArg(description = "Average LLM input tokens per request
(default: 1000)") Integer avgInputTokens,
+ @ToolArg(description = "Average LLM output tokens per request
(default: 500)") Integer avgOutputTokens) {
+
+ if (route == null || route.isBlank()) {
+ throw new ToolCallException("Route content is required", null);
+ }
+
+ try {
+ return doEstimate(route, messagesPerHour, avgPages,
avgInputTokens, avgOutputTokens);
+ } catch (ToolCallException e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new ToolCallException(
+ "Failed to estimate route cost (" + e.getClass().getName()
+ "): " + e.getMessage(), null);
+ }
+ }
+
+ private CostEstimateResult doEstimate(
+ String route, Integer messagesPerHour, Integer avgPages,
+ Integer avgInputTokens, Integer avgOutputTokens) {
+
+ int throughput = messagesPerHour != null && messagesPerHour > 0 ?
messagesPerHour : 100;
+ int pages = avgPages != null && avgPages > 0 ? avgPages : 5;
+ int inTokens = avgInputTokens != null && avgInputTokens > 0 ?
avgInputTokens : 1000;
+ int outTokens = avgOutputTokens != null && avgOutputTokens > 0 ?
avgOutputTokens : 500;
+
+ List<String> detectedSchemes = extractSchemes(route);
+
+ List<ComponentCostBreakdown> breakdown = new ArrayList<>();
+ double totalPerExecution = 0;
+
+ for (String scheme : detectedSchemes) {
+ ComponentCostProfile profile = COST_PROFILES.get(scheme);
+ if (profile == null) {
+ continue;
+ }
+
+ double costPerExec = profile.estimateCostPerExecution(pages,
inTokens, outTokens);
+ totalPerExecution += costPerExec;
+ breakdown.add(new ComponentCostBreakdown(
+ scheme, profile.displayName(), profile.pricingModel(),
+ costPerExec, profile.pricingNote()));
+ }
+
+
breakdown.sort(Comparator.comparingDouble(ComponentCostBreakdown::estimatedCostPerExecution).reversed());
+
+ double hourly = totalPerExecution * throughput;
+ double daily = hourly * 24;
+ double monthly = daily * 30;
+
+ String mostExpensive = breakdown.isEmpty() ? null :
breakdown.get(0).scheme();
+
+ List<String> optimizationTips = buildOptimizationTips(detectedSchemes,
breakdown);
+
+ CostProjection projection = new CostProjection(
+ throughput,
+ formatCost(hourly), formatCost(daily), formatCost(monthly));
+
+ CostSummary summary = new CostSummary(
+ formatCost(totalPerExecution),
+ breakdown.size(),
+ detectedSchemes.size(),
+ mostExpensive,
+ breakdown.isEmpty() ? "No pay-per-use components detected in
this route" : null);
+
+ return new CostEstimateResult(
+ breakdown.isEmpty() ? null : breakdown,
+ projection, summary,
+ optimizationTips.isEmpty() ? null : optimizationTips,
+ "Cost estimates are approximate based on published AWS pricing
(us-east-1). "
+ +
"Actual costs vary by region, volume tier, and model.",
+ "2025-Q2");
+ }
+
+ List<String> extractSchemes(String route) {
+ List<String> schemes = new ArrayList<>();
+ addSchemeMatches(schemes, YAML_SCHEME_PATTERN, route);
+ addSchemeMatches(schemes, XML_SCHEME_PATTERN, route);
+ return schemes;
+ }
+
+ private void addSchemeMatches(List<String> schemes, Pattern pattern,
String route) {
+ Matcher m = pattern.matcher(route);
+ while (m.find()) {
+ String scheme = m.group(1);
+ if (!schemes.contains(scheme)) {
+ schemes.add(scheme);
+ }
+ }
+ }
+
+ private List<String> buildOptimizationTips(List<String> schemes,
List<ComponentCostBreakdown> breakdown) {
+ List<String> tips = new ArrayList<>();
+
+ if (schemes.contains("aws-bedrock")) {
+ tips.add("Consider using a smaller/cheaper Bedrock model for
simpler tasks "
+ + "(e.g., Nova Lite instead of Claude for
classification)");
+ tips.add("Use streaming to reduce perceived latency without
affecting token costs");
+ }
+ if (schemes.contains("aws2-textract") && schemes.contains("docling")) {
+ tips.add("Using both Textract and Docling — consider using only
Docling (free, open-source) "
+ + "for text extraction and reserving Textract for
table/form extraction");
+ }
+ if (schemes.contains("aws2-s3")) {
+ tips.add("S3 GET costs are minimal but add up at scale — consider
caching frequently accessed documents");
+ }
+ if (breakdown.size() > 1) {
+ tips.add("Most expensive component: " +
breakdown.get(0).displayName()
+ + " — focus optimization here for maximum savings");
+ }
+ return tips;
+ }
+
+ private static String formatCost(double cost) {
+ if (cost < 0.01) {
+ return String.format("$%.6f", cost);
+ }
+ return String.format("$%.4f", cost);
+ }
+
+ private static Map<String, ComponentCostProfile> buildCostProfiles() {
+ Map<String, ComponentCostProfile> profiles = new LinkedHashMap<>();
+
+ profiles.put("aws-bedrock", new ComponentCostProfile(
+ "AWS Bedrock (model-dependent, estimated as Claude Sonnet 4)",
"per-token",
+ "Estimate uses Claude Sonnet 4 pricing ($3/$15 per MTok).
Actual cost depends on model: "
+
+ "Nova Lite ~$0.06/$0.24, Haiku ~$0.25/$1.25, Opus ~$15/$75") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return (inputTokens * 3.0 / 1_000_000) + (outputTokens * 15.0
/ 1_000_000);
+ }
+ });
+
+ profiles.put("aws-bedrock-agent-runtime", new ComponentCostProfile(
+ "AWS Bedrock Agent Runtime (RAG/Flows)", "per-session",
+ "RetrieveAndGenerate: model invocation cost + ~$0.02/1000
retrieval units. "
+ +
"InvokeFlow: depends on flow steps") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return (inputTokens * 3.0 / 1_000_000) + (outputTokens * 15.0
/ 1_000_000) + 0.02 / 1000;
+ }
+ });
+
+ profiles.put("aws2-textract", new ComponentCostProfile(
+ "AWS Textract", "per-page",
+ "DetectDocumentText: ~$1.50/1000 pages, AnalyzeDocument:
~$15/1000 pages") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return pages * 1.50 / 1000;
+ }
+ });
+
+ profiles.put("docling", new ComponentCostProfile(
+ "Docling (self-hosted)", "free",
+ "Open-source, self-hosted. Cost is only infrastructure
(compute/memory)") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return 0;
+ }
+ });
+
+ profiles.put("aws2-s3", new ComponentCostProfile(
+ "AWS S3", "per-request",
+ "GET: ~$0.40/1M requests, PUT: ~$5/1M requests") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return 0.40 / 1_000_000;
+ }
+ });
+
+ profiles.put("aws2-sqs", new ComponentCostProfile(
+ "AWS SQS", "per-request",
+ "~$0.40 per 1M requests (Standard)") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return 0.40 / 1_000_000;
+ }
+ });
+
+ profiles.put("aws2-sns", new ComponentCostProfile(
+ "AWS SNS", "per-publish",
+ "~$0.50 per 1M publishes") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return 0.50 / 1_000_000;
+ }
+ });
+
+ profiles.put("aws2-kinesis", new ComponentCostProfile(
+ "AWS Kinesis", "per-shard-hour + per-PUT",
+ "~$0.015/shard/hour + $0.014/1M PUT units") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return 0.014 / 1_000_000;
+ }
+ });
+
+ profiles.put("langchain4j-chat", new ComponentCostProfile(
+ "LangChain4j Chat (model-dependent)", "per-token",
+ "Cost depends on the underlying model provider. Estimated as
Claude Sonnet 4; "
+ + "free
with Ollama/local models") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return (inputTokens * 3.0 / 1_000_000) + (outputTokens * 15.0
/ 1_000_000);
+ }
+ });
+
+ profiles.put("langchain4j-embeddings", new ComponentCostProfile(
+ "LangChain4j Embeddings (model-dependent)", "per-token",
+ "Estimated as Titan Embed (~$0.02/1M tokens); free with
Ollama/local models") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return inputTokens * 0.02 / 1_000_000;
+ }
+ });
+
+ profiles.put("openai", new ComponentCostProfile(
+ "OpenAI API", "per-token",
+ "GPT-4o: ~$2.50/MTok input, ~$10/MTok output") {
+ @Override
+ double estimateCostPerExecution(int pages, int inputTokens, int
outputTokens) {
+ return (inputTokens * 2.50 / 1_000_000) + (outputTokens * 10.0
/ 1_000_000);
+ }
+ });
+
+ return profiles;
+ }
+
+ // ---- Cost profile base ----
+
+ abstract static class ComponentCostProfile {
+ private final String displayName;
+ private final String pricingModel;
+ private final String pricingNote;
+
+ ComponentCostProfile(String displayName, String pricingModel, String
pricingNote) {
+ this.displayName = displayName;
+ this.pricingModel = pricingModel;
+ this.pricingNote = pricingNote;
+ }
+
+ String displayName() {
+ return displayName;
+ }
+
+ String pricingModel() {
+ return pricingModel;
+ }
+
+ String pricingNote() {
+ return pricingNote;
+ }
+
+ abstract double estimateCostPerExecution(int pages, int inputTokens,
int outputTokens);
+ }
+
+ // ---- Result records ----
+
+ public record CostEstimateResult(
+ List<ComponentCostBreakdown> costBreakdown,
+ CostProjection projection,
+ CostSummary summary,
+ List<String> optimizationTips,
+ String disclaimer,
+ String pricingDataAsOf) {
+ }
+
+ public record ComponentCostBreakdown(
+ String scheme,
+ String displayName,
+ String pricingModel,
+ double estimatedCostPerExecution,
+ String pricingNote) {
+ }
+
+ public record CostProjection(
+ int messagesPerHour,
+ String hourly,
+ String daily,
+ String monthly) {
+ }
+
+ public record CostSummary(
+ String estimatedCostPerExecution,
+ int pricedComponentCount,
+ int totalComponentCount,
+ String mostExpensiveComponent,
+ String note) {
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateToolsTest.java
new file mode 100644
index 000000000000..f66289e24488
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteCostEstimateToolsTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.dsl.jbang.core.commands.mcp;
+
+import java.util.List;
+
+import io.quarkiverse.mcp.server.ToolCallException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RouteCostEstimateToolsTest {
+
+ private RouteCostEstimateTools tools;
+
+ @BeforeEach
+ void setUp() {
+ tools = new RouteCostEstimateTools();
+ }
+
+ private static final String AI_PIPELINE_ROUTE = """
+ - route:
+ id: ai-summarization
+ from:
+ uri: "file:documents"
+ steps:
+ - to: "docling:convert"
+ - to: "aws-bedrock:label"
+ - to: "aws2-s3:output-bucket"
+ """;
+
+ private static final String TEXTRACT_ROUTE = """
+ - route:
+ from:
+ uri: "aws2-s3:input-bucket"
+ steps:
+ - to: "aws2-textract:detect"
+ - to: "aws-bedrock:label"
+ """;
+
+ private static final String SIMPLE_ROUTE = """
+ - route:
+ from:
+ uri: "timer:tick"
+ steps:
+ - to: "log:out"
+ """;
+
+ @Test
+ void shouldRequireRouteContent() {
+ assertThatThrownBy(() -> tools.camel_route_cost_estimate(null, null,
null, null, null))
+ .isInstanceOf(ToolCallException.class)
+ .hasMessageContaining("Route content is required");
+ }
+
+ @Test
+ void shouldEstimateCostForAiPipeline() {
+ RouteCostEstimateTools.CostEstimateResult result
+ = tools.camel_route_cost_estimate(AI_PIPELINE_ROUTE, 100, 5,
1000, 500);
+
+ assertThat(result).isNotNull();
+ assertThat(result.costBreakdown()).isNotNull();
+ assertThat(result.costBreakdown()).hasSizeGreaterThanOrEqualTo(2);
+
assertThat(result.summary().mostExpensiveComponent()).isEqualTo("aws-bedrock");
+ assertThat(result.projection().messagesPerHour()).isEqualTo(100);
+ assertThat(result.disclaimer()).isNotBlank();
+ }
+
+ @Test
+ void shouldIdentifyBedrockAsMostExpensive() {
+ RouteCostEstimateTools.CostEstimateResult result
+ = tools.camel_route_cost_estimate(TEXTRACT_ROUTE, null, null,
null, null);
+
+ assertThat(result.costBreakdown()).isNotNull();
+
assertThat(result.costBreakdown().get(0).scheme()).isEqualTo("aws-bedrock");
+ }
+
+ @Test
+ void shouldReportNoPricedComponentsForSimpleRoute() {
+ RouteCostEstimateTools.CostEstimateResult result
+ = tools.camel_route_cost_estimate(SIMPLE_ROUTE, null, null,
null, null);
+
+ assertThat(result.costBreakdown()).isNull();
+ assertThat(result.summary().note()).contains("No pay-per-use
components");
+ }
+
+ @Test
+ void shouldIncludeDoclingAsFree() {
+ RouteCostEstimateTools.CostEstimateResult result
+ = tools.camel_route_cost_estimate(AI_PIPELINE_ROUTE, null,
null, null, null);
+
+ assertThat(result.costBreakdown())
+ .anyMatch(c -> "docling".equals(c.scheme()) &&
c.estimatedCostPerExecution() == 0);
+ }
+
+ @Test
+ void shouldProjectMonthlyCosts() {
+ RouteCostEstimateTools.CostEstimateResult result
+ = tools.camel_route_cost_estimate(TEXTRACT_ROUTE, 1000, 10,
2000, 1000);
+
+ assertThat(result.projection()).isNotNull();
+ assertThat(result.projection().monthly()).startsWith("$");
+ assertThat(result.projection().messagesPerHour()).isEqualTo(1000);
+ }
+
+ @Test
+ void shouldProvideOptimizationTips() {
+ RouteCostEstimateTools.CostEstimateResult result
+ = tools.camel_route_cost_estimate(AI_PIPELINE_ROUTE, null,
null, null, null);
+
+ assertThat(result.optimizationTips()).isNotNull();
+ assertThat(result.optimizationTips()).isNotEmpty();
+ }
+
+ @Test
+ void shouldExtractSchemesCorrectly() {
+ List<String> schemes = tools.extractSchemes(AI_PIPELINE_ROUTE);
+
+ assertThat(schemes).contains("docling", "aws-bedrock", "aws2-s3");
+ assertThat(schemes).doesNotContain("uri");
+ }
+
+ @Test
+ void shouldUseDefaultsForNullParams() {
+ RouteCostEstimateTools.CostEstimateResult result
+ = tools.camel_route_cost_estimate(AI_PIPELINE_ROUTE, null,
null, null, null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.projection().messagesPerHour()).isEqualTo(100);
+ }
+
+ @Test
+ void shouldExtractSchemesFromXmlRoutes() {
+ String xmlRoute = """
+ <route>
+ <from uri="aws2-s3:bucket"/>
+ <to uri="aws-bedrock:label"/>
+ </route>
+ """;
+
+ List<String> schemes = tools.extractSchemes(xmlRoute);
+
+ assertThat(schemes).contains("aws2-s3", "aws-bedrock");
+ }
+
+ @Test
+ void shouldHandleTextractAndDoclingCombined() {
+ String combinedRoute = """
+ - route:
+ from:
+ uri: "file:docs"
+ steps:
+ - to: "docling:convert"
+ - to: "aws2-textract:detect"
+ - to: "aws-bedrock:label"
+ """;
+
+ RouteCostEstimateTools.CostEstimateResult result
+ = tools.camel_route_cost_estimate(combinedRoute, null, null,
null, null);
+
+ assertThat(result.optimizationTips())
+ .anyMatch(t -> t.contains("Textract") &&
t.contains("Docling"));
+ }
+}