This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 362d112c7cbb CAMEL-24721: camel-openai - agentic token budget and
observability in the Responses tool loop
362d112c7cbb is described below
commit 362d112c7cbb8ccadf429141f48001f74ca0203a
Author: Karol Krawczyk <[email protected]>
AuthorDate: Tue Sep 22 08:33:57 2026 +0200
CAMEL-24721: camel-openai - agentic token budget and observability in the
Responses tool loop
The openai:responses tool loop now does the same agentic bookkeeping as
chat-completion: the CamelOpenAIAgenticPromptTokens/CompletionTokens/
TotalTokens headers report cumulative usage after every model call,
maxAgenticTokens refuses the next model call once the budget is spent (an
answer that goes over the budget is still returned), one
CamelOpenAIAgenticTrace entry is recorded per model call with the tool
calls of that iteration, and the OpenAIAgenticLoopStarted/
ToolCallExecuted/LoopCompleted events are emitted with the finish status,
return_direct, token_budget_exceeded or max_iterations_exceeded as stop
reason.
OpenAIAgenticTokenTracker gains a ResponseUsage overload (input/output
tokens counted under the prompt/completion names) plus setHeaders and
exceedsBudget, which both producers now share. OpenAIResponsesAgenticTest
covers the headers, the budget, the trace, the events and the stop
reasons. The openai-responses and openai-mcp docs no longer say the budget,
trace and events are chat-completion only.
Closes #26707
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
.../org/apache/camel/catalog/docs/openai-mcp.adoc | 2 +
.../camel/catalog/docs/openai-responses.adoc | 6 +-
.../camel-openai/src/main/docs/openai-mcp.adoc | 2 +
.../src/main/docs/openai-responses.adoc | 6 +-
.../openai/OpenAIAgenticTokenTracker.java | 28 ++-
.../camel/component/openai/OpenAIProducer.java | 15 +-
.../component/openai/OpenAIResponsesProducer.java | 134 +++++++----
.../openai/OpenAIResponsesAgenticTest.java | 244 +++++++++++++++++++++
8 files changed, 381 insertions(+), 56 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
index 1a92fce221b2..70084fa6472b 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc
@@ -253,6 +253,8 @@ A final text response is still returned when cumulative
usage exceeds the budget
During the agentic loop, `CamelOpenAIAgenticPromptTokens`,
`CamelOpenAIAgenticCompletionTokens`, and `CamelOpenAIAgenticTotalTokens`
expose cumulative usage across all iterations.
The per-call headers `CamelOpenAIPromptTokens`, `CamelOpenAICompletionTokens`,
and `CamelOpenAITotalTokens` reflect only the latest API call.
+The xref:others:openai-responses.adoc[responses operation] runs the same loop
over the Responses API, with the same budget, headers, trace and events.
+
Set `autoToolExecution=false` to disable the agentic loop and receive raw tool
calls in the message body instead:
[tabs]
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-responses.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-responses.adoc
index 7c2ba4f66c90..9abaef777642 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-responses.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-responses.adoc
@@ -156,7 +156,11 @@ loop: it executes the calls, sends their results back
together with the conversa
model answers or `maxToolIterations` is exceeded.
`toolExecutionErrorStrategy`, `hallucinatedToolNameStrategy`,
`parallelToolExecution` and tools returning directly behave as they do for
`chat-completion`, and the
`CamelOpenAIToolIterations`, `CamelOpenAIMcpToolCalls` and
`CamelOpenAIMcpReturnDirect` headers report what the loop did.
-`maxAgenticTokens` and the agentic trace and events are only supported by
`chat-completion`.
+`maxAgenticTokens` caps the cumulative usage of the loop, the
`CamelOpenAIAgenticPromptTokens`,
+`CamelOpenAIAgenticCompletionTokens` and `CamelOpenAIAgenticTotalTokens`
headers report that usage after every call of
+the model, and the loop publishes the `CamelOpenAIAgenticTrace` exchange
property and the agentic lifecycle events
+described in xref:others:openai-mcp.adoc[MCP Tool Calling]. The Responses API
counts input and output tokens where
+`chat-completion` counts prompt and completion tokens; the headers keep the
`chat-completion` names.
With `autoToolExecution=false`, the model is not called again: the function
calls it requested are returned as the
message body, a `List` of
`com.openai.models.responses.ResponseFunctionToolCall`, for the route to handle.
diff --git a/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
b/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
index 1a92fce221b2..70084fa6472b 100644
--- a/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
+++ b/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc
@@ -253,6 +253,8 @@ A final text response is still returned when cumulative
usage exceeds the budget
During the agentic loop, `CamelOpenAIAgenticPromptTokens`,
`CamelOpenAIAgenticCompletionTokens`, and `CamelOpenAIAgenticTotalTokens`
expose cumulative usage across all iterations.
The per-call headers `CamelOpenAIPromptTokens`, `CamelOpenAICompletionTokens`,
and `CamelOpenAITotalTokens` reflect only the latest API call.
+The xref:others:openai-responses.adoc[responses operation] runs the same loop
over the Responses API, with the same budget, headers, trace and events.
+
Set `autoToolExecution=false` to disable the agentic loop and receive raw tool
calls in the message body instead:
[tabs]
diff --git
a/components/camel-ai/camel-openai/src/main/docs/openai-responses.adoc
b/components/camel-ai/camel-openai/src/main/docs/openai-responses.adoc
index 7c2ba4f66c90..9abaef777642 100644
--- a/components/camel-ai/camel-openai/src/main/docs/openai-responses.adoc
+++ b/components/camel-ai/camel-openai/src/main/docs/openai-responses.adoc
@@ -156,7 +156,11 @@ loop: it executes the calls, sends their results back
together with the conversa
model answers or `maxToolIterations` is exceeded.
`toolExecutionErrorStrategy`, `hallucinatedToolNameStrategy`,
`parallelToolExecution` and tools returning directly behave as they do for
`chat-completion`, and the
`CamelOpenAIToolIterations`, `CamelOpenAIMcpToolCalls` and
`CamelOpenAIMcpReturnDirect` headers report what the loop did.
-`maxAgenticTokens` and the agentic trace and events are only supported by
`chat-completion`.
+`maxAgenticTokens` caps the cumulative usage of the loop, the
`CamelOpenAIAgenticPromptTokens`,
+`CamelOpenAIAgenticCompletionTokens` and `CamelOpenAIAgenticTotalTokens`
headers report that usage after every call of
+the model, and the loop publishes the `CamelOpenAIAgenticTrace` exchange
property and the agentic lifecycle events
+described in xref:others:openai-mcp.adoc[MCP Tool Calling]. The Responses API
counts input and output tokens where
+`chat-completion` counts prompt and completion tokens; the headers keep the
`chat-completion` names.
With `autoToolExecution=false`, the model is not called again: the function
calls it requested are returned as the
message body, a `List` of
`com.openai.models.responses.ResponseFunctionToolCall`, for the route to handle.
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
index 016bee75e971..c51e20ce92cc 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIAgenticTokenTracker.java
@@ -18,9 +18,12 @@ package org.apache.camel.component.openai;
import com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.completions.CompletionUsage;
+import com.openai.models.responses.Response;
+import com.openai.models.responses.ResponseUsage;
+import org.apache.camel.Message;
/**
- * Tracks cumulative token usage across the MCP agentic loop.
+ * Tracks cumulative token usage across the agentic loop of the
chat-completion and responses operations.
*/
final class OpenAIAgenticTokenTracker {
@@ -54,6 +57,29 @@ final class OpenAIAgenticTokenTracker {
completionTokens += usage.completionTokens();
}
+ void addUsage(Response response) {
+ if (response == null) {
+ return;
+ }
+ response.usage().ifPresent(this::addUsage);
+ }
+
+ /** The Responses API counts the same tokens under the input and output
names. */
+ void addUsage(ResponseUsage usage) {
+ promptTokens += usage.inputTokens();
+ completionTokens += usage.outputTokens();
+ }
+
+ void setHeaders(Message message) {
+ message.setHeader(OpenAIConstants.AGENTIC_PROMPT_TOKENS, promptTokens);
+ message.setHeader(OpenAIConstants.AGENTIC_COMPLETION_TOKENS,
completionTokens);
+ message.setHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS,
getTotalTokens());
+ }
+
+ boolean exceedsBudget(long maxAgenticTokens) {
+ return maxAgenticTokens > 0 && getTotalTokens() > maxAgenticTokens;
+ }
+
long getPromptTokens() {
return promptTokens;
}
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
index 0e674e648b91..3c0851290729 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
@@ -381,7 +381,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
ChatCompletion response = createChatCompletion(exchange,
paramsBuilder.build());
tokenTracker.addUsage(response);
- setAgenticTokenHeaders(exchange.getMessage(), tokenTracker);
+ tokenTracker.setHeaders(exchange.getMessage());
long iterationPromptTokens =
tokenTracker.promptTokensSince(tokensBefore);
long iterationCompletionTokens =
tokenTracker.completionTokensSince(tokensBefore);
@@ -412,7 +412,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
return;
}
- if (tokenBudgetExceeded(config, tokenTracker)) {
+ if (tokenTracker.exceedsBudget(config.getMaxAgenticTokens())) {
observability.recordFinalIteration(
modelCall, iterationStartNanos,
iterationPromptTokens, iterationCompletionTokens);
stopReason = "token_budget_exceeded";
@@ -497,17 +497,6 @@ public class OpenAIProducer extends DefaultAsyncProducer {
}
}
- private void setAgenticTokenHeaders(Message message,
OpenAIAgenticTokenTracker tokenTracker) {
- message.setHeader(OpenAIConstants.AGENTIC_PROMPT_TOKENS,
tokenTracker.getPromptTokens());
- message.setHeader(OpenAIConstants.AGENTIC_COMPLETION_TOKENS,
tokenTracker.getCompletionTokens());
- message.setHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS,
tokenTracker.getTotalTokens());
- }
-
- private static boolean tokenBudgetExceeded(OpenAIConfiguration config,
OpenAIAgenticTokenTracker tokenTracker) {
- long maxAgenticTokens = config.getMaxAgenticTokens();
- return maxAgenticTokens > 0 && tokenTracker.getTotalTokens() >
maxAgenticTokens;
- }
-
private void processStreaming(Exchange exchange,
ChatCompletionCreateParams params) {
String requestModel = params.model().toString();
ChatCompletionCreateParams.Builder streamingBuilder =
params.toBuilder();
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java
index a57c95cb7be3..c5739308c114 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIResponsesProducer.java
@@ -22,6 +22,7 @@ import java.util.List;
import java.util.stream.Collectors;
import com.openai.models.chat.completions.ChatCompletionFunctionTool;
+import com.openai.models.chat.completions.ChatCompletionMessageToolCall;
import com.openai.models.responses.Response;
import com.openai.models.responses.ResponseCreateParams;
import com.openai.models.responses.ResponseFunctionToolCall;
@@ -223,51 +224,104 @@ public class OpenAIResponsesProducer extends
DefaultAsyncProducer {
List<ResponseInputItem> conversation = new ArrayList<>(input);
List<ResponseInputItem> requestInput = input;
List<String> toolCallsLog = new ArrayList<>();
+ OpenAIAgenticTokenTracker tokenTracker = new
OpenAIAgenticTokenTracker();
+ OpenAIAgenticObservability observability = new
OpenAIAgenticObservability(exchange);
+
observability.onLoopStarted(getEndpoint().getMcpToolState().knownToolNames().size(),
config.getMaxToolIterations());
int iteration = 0;
+ int modelCall = 0;
+ String stopReason = "unknown";
- // as in chat-completion, at most maxToolIterations model calls are
made
- while (iteration < config.getMaxToolIterations()) {
- Response response = createResponse(exchange, model,
params.toBuilder().inputOfResponse(requestInput).build());
- List<ResponseFunctionToolCall> functionCalls =
OpenAIResponsesSupport.extractFunctionCalls(response);
- if (functionCalls.isEmpty()) {
- finishExchange(exchange, config, response,
OpenAIResponsesSupport.extractAssistantText(response));
- setToolHeaders(exchange.getMessage(), iteration, toolCallsLog,
false);
- return;
- }
- iteration++;
-
- functionCalls.forEach(call -> toolCallsLog.add(call.name()));
- List<McpToolCallExecutor.ToolResult> results
- =
toolCallExecutor.execute(OpenAIResponsesSupport.toChatToolCalls(functionCalls));
-
- if
(results.stream().allMatch(McpToolCallExecutor.ToolResult::returnDirect)) {
- // the results are not sent back, so conversation memory is
not moved to this response
- Message out = exchange.getMessage();
- out.setBody(results.stream()
- .map(McpToolCallExecutor.ToolResult::content)
- .collect(Collectors.joining("\n")));
- setResponseHeaders(out, response);
- setToolHeaders(out, iteration, toolCallsLog, true);
- return;
+ try {
+ // as in chat-completion, at most maxToolIterations model calls
are made
+ while (iteration < config.getMaxToolIterations()) {
+ modelCall++;
+ long iterationStartNanos = System.nanoTime();
+ OpenAIAgenticTokenTracker.Snapshot tokensBefore =
tokenTracker.snapshot();
+
+ Response response
+ = createResponse(exchange, model,
params.toBuilder().inputOfResponse(requestInput).build());
+ tokenTracker.addUsage(response);
+ tokenTracker.setHeaders(exchange.getMessage());
+ long iterationPromptTokens =
tokenTracker.promptTokensSince(tokensBefore);
+ long iterationCompletionTokens =
tokenTracker.completionTokensSince(tokensBefore);
+
+ List<ResponseFunctionToolCall> functionCalls =
OpenAIResponsesSupport.extractFunctionCalls(response);
+ if (functionCalls.isEmpty()) {
+ stopReason =
OpenAIResponsesSupport.extractFinishStatus(response)
+ .map(OpenAIResponsesProducer::mapFinishReason)
+ .orElse("stop");
+ observability.recordFinalIteration(
+ modelCall, iterationStartNanos,
iterationPromptTokens, iterationCompletionTokens);
+ observability.onLoopCompleted(iteration, tokenTracker,
stopReason);
+ finishExchange(exchange, config, response,
OpenAIResponsesSupport.extractAssistantText(response));
+ setToolHeaders(exchange.getMessage(), iteration,
toolCallsLog, false);
+ return;
+ }
+
+ // an answer that goes over the budget is still returned, only
a further model call is refused
+ if (tokenTracker.exceedsBudget(config.getMaxAgenticTokens())) {
+ observability.recordFinalIteration(
+ modelCall, iterationStartNanos,
iterationPromptTokens, iterationCompletionTokens);
+ stopReason = "token_budget_exceeded";
+ throw new IllegalStateException(
+ "Max agentic tokens (%d) exceeded at iteration %d.
Cumulative usage: prompt=%d, completion=%d, total=%d"
+ .formatted(config.getMaxAgenticTokens(),
iteration, tokenTracker.getPromptTokens(),
+
tokenTracker.getCompletionTokens(), tokenTracker.getTotalTokens()));
+ }
+
+ iteration++;
+
+ functionCalls.forEach(call -> toolCallsLog.add(call.name()));
+ List<ChatCompletionMessageToolCall> toolCalls =
OpenAIResponsesSupport.toChatToolCalls(functionCalls);
+ List<McpToolCallExecutor.ToolResult> results =
toolCallExecutor.execute(toolCalls);
+ observability.recordIteration(
+ modelCall, iterationStartNanos, iterationPromptTokens,
iterationCompletionTokens, toolCalls,
+ results);
+
+ if
(results.stream().allMatch(McpToolCallExecutor.ToolResult::returnDirect)) {
+ // the results are not sent back, so conversation memory
is not moved to this response
+ Message out = exchange.getMessage();
+ out.setBody(results.stream()
+ .map(McpToolCallExecutor.ToolResult::content)
+ .collect(Collectors.joining("\n")));
+ setResponseHeaders(out, response);
+ setToolHeaders(out, iteration, toolCallsLog, true);
+ stopReason = "return_direct";
+ observability.onLoopCompleted(iteration, tokenTracker,
stopReason);
+ return;
+ }
+ List<ResponseInputItem> toolOutputs = results.stream()
+ .map(result ->
ResponseInputItem.ofFunctionCallOutput(ResponseInputItem.FunctionCallOutput.builder()
+ .callId(result.toolCallId())
+ .output(result.content())
+ .build()))
+ .toList();
+ if (storedConversation) {
+ // the server already added the function calls of this
response to the conversation
+ requestInput = toolOutputs;
+ } else {
+ // the function calls, and the reasoning that led to them,
must precede their results
+
conversation.addAll(OpenAIResponsesSupport.toInputItems(response));
+ conversation.addAll(toolOutputs);
+ requestInput = conversation;
+ }
}
- List<ResponseInputItem> toolOutputs = results.stream()
- .map(result ->
ResponseInputItem.ofFunctionCallOutput(ResponseInputItem.FunctionCallOutput.builder()
- .callId(result.toolCallId())
- .output(result.content())
- .build()))
- .toList();
- if (storedConversation) {
- // the server already added the function calls of this
response to the conversation
- requestInput = toolOutputs;
- } else {
- // the function calls, and the reasoning that led to them,
must precede their results
-
conversation.addAll(OpenAIResponsesSupport.toInputItems(response));
- conversation.addAll(toolOutputs);
- requestInput = conversation;
+ stopReason = "max_iterations_exceeded";
+ observability.onLoopCompleted(config.getMaxToolIterations(),
tokenTracker, stopReason);
+ throw new IllegalStateException(
+ "Max tool iterations (%d) exceeded. Tools called: %s"
+ .formatted(config.getMaxToolIterations(),
toolCallsLog));
+ } catch (IllegalStateException e) {
+ if ("unknown".equals(stopReason)) {
+ stopReason = "error";
}
+ throw e;
+ } catch (Exception e) {
+ stopReason = "error";
+ throw e;
+ } finally {
+ observability.finalizeObservability(tokenTracker, iteration,
stopReason);
}
- throw new IllegalStateException(
- "Max tool iterations (%d) exceeded. Tools called:
%s".formatted(config.getMaxToolIterations(), toolCallsLog));
}
private Response createResponse(Exchange exchange, String model,
ResponseCreateParams params) throws Exception {
diff --git
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIResponsesAgenticTest.java
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIResponsesAgenticTest.java
new file mode 100644
index 000000000000..ab28ca94defd
--- /dev/null
+++
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIResponsesAgenticTest.java
@@ -0,0 +1,244 @@
+/*
+ * 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.openai;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.spi.CamelEvent;
+import org.apache.camel.support.EventNotifierSupport;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The token budget, the trace and the lifecycle events of the tool loop of
the responses operation, which reports them
+ * as the chat-completion loop does.
+ */
+class OpenAIResponsesAgenticTest extends CamelTestSupport {
+
+ private final List<CamelEvent> events = new CopyOnWriteArrayList<>();
+
+ @RegisterExtension
+ public OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("one tool")
+ .withUsage(10, 5)
+ .invokeTool("get_weather")
+ .withParam("city", "London")
+ .replyWith("The weather in London is sunny.")
+ .end()
+ .when("expensive tool call")
+ .withUsage(70, 50)
+ .invokeTool("get_weather")
+ .withParam("city", "Paris")
+ .replyWith("Should not reach this response")
+ .end()
+ .when("expensive direct answer")
+ .withUsage(70, 50)
+ .replyWith("Direct answer over budget")
+ .end()
+ .when("accumulate over budget")
+ .withUsage(40, 10)
+ .invokeTool("get_weather")
+ .withParam("city", "A")
+ .andThenInvokeTool("get_weather")
+ .withParam("city", "B")
+ .replyWith("Should not reach this response")
+ .end()
+ .when("look up an order")
+ .invokeTool("lookup_order")
+ .withParam("id", "42")
+ .replyWith("Should not reach this response, the tool returns
directly")
+ .end()
+ .build();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ String base = openAIMock.getBaseUrl() + "/v1";
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("ai-tool:get_weather?tags=responses-agentic&description=Get the weather
for a city"
+ + "¶meter.city=string¶meter.city.required=true")
+ .setBody(simple("Sunny in ${header.city}"));
+
+
from("ai-tool:lookup_order?tags=responses-agentic-direct&description=Look up an
order"
+ +
"¶meter.id=string¶meter.id.required=true&returnDirect=true")
+ .setBody(simple("Order ${header.id} shipped"));
+
+ from("direct:responses-agentic")
+
.to("openai:responses?model=gpt-5&apiKey=dummy&tags=responses-agentic&baseUrl="
+ base);
+
+ from("direct:responses-agentic-budget")
+
.to("openai:responses?model=gpt-5&apiKey=dummy&tags=responses-agentic"
+ +
"&maxAgenticTokens=100&maxToolIterations=5&baseUrl=" + base);
+
+ from("direct:responses-agentic-budget-multi")
+
.to("openai:responses?model=gpt-5&apiKey=dummy&tags=responses-agentic"
+ +
"&maxAgenticTokens=80&maxToolIterations=5&baseUrl=" + base);
+
+ from("direct:responses-agentic-direct")
+
.to("openai:responses?model=gpt-5&apiKey=dummy&tags=responses-agentic-direct&baseUrl="
+ base);
+ }
+ };
+ }
+
+ @BeforeEach
+ void registerEventNotifier() {
+ events.clear();
+ context.getManagementStrategy().addEventNotifier(new
EventNotifierSupport() {
+ @Override
+ public void notify(CamelEvent event) {
+ if (event.getType() == CamelEvent.Type.Custom) {
+ events.add(event);
+ }
+ }
+
+ @Override
+ public boolean isEnabled(CamelEvent event) {
+ return event.getType() == CamelEvent.Type.Custom;
+ }
+ });
+ }
+
+ @Test
+ void theTokenHeadersCoverEveryCallOfTheLoop() {
+ Exchange result = template.request("direct:responses-agentic", e ->
e.getIn().setBody("one tool"));
+
+ assertThat(result.getException()).isNull();
+ assertThat(result.getMessage().getBody(String.class)).isEqualTo("The
weather in London is sunny.");
+ // the tool call and the answer that followed it, 10 prompt and 5
completion tokens each
+
assertThat(result.getMessage().getHeader(OpenAIConstants.AGENTIC_PROMPT_TOKENS,
Long.class)).isEqualTo(20L);
+
assertThat(result.getMessage().getHeader(OpenAIConstants.AGENTIC_COMPLETION_TOKENS,
Long.class)).isEqualTo(10L);
+
assertThat(result.getMessage().getHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS,
Long.class)).isEqualTo(30L);
+ // the headers of the last response stay untouched
+
assertThat(result.getMessage().getHeader(OpenAIConstants.PROMPT_TOKENS,
Long.class)).isEqualTo(10L);
+ }
+
+ @Test
+ void maxAgenticTokensRefusesTheNextCall() {
+ Exchange result = template.request("direct:responses-agentic-budget",
+ e -> e.getIn().setBody("expensive tool call"));
+
+
assertThat(result.getException()).isInstanceOf(IllegalStateException.class);
+ assertThat(result.getException().getMessage())
+ .contains("Max agentic tokens (100) exceeded at iteration 0")
+ .contains("prompt=70")
+ .contains("completion=50")
+ .contains("total=120");
+
assertThat(result.getMessage().getHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS,
Long.class)).isEqualTo(120L);
+ }
+
+ @Test
+ void maxAgenticTokensCountsAcrossIterations() {
+ Exchange result =
template.request("direct:responses-agentic-budget-multi",
+ e -> e.getIn().setBody("accumulate over budget"));
+
+
assertThat(result.getException()).isInstanceOf(IllegalStateException.class);
+ assertThat(result.getException().getMessage())
+ .contains("Max agentic tokens (80) exceeded at iteration 1")
+ .contains("total=100");
+
assertThat(result.getMessage().getHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS,
Long.class)).isEqualTo(100L);
+ }
+
+ @Test
+ void anAnswerOverTheBudgetIsStillReturned() {
+ Exchange result = template.request("direct:responses-agentic-budget",
+ e -> e.getIn().setBody("expensive direct answer"));
+
+ assertThat(result.getException()).isNull();
+
assertThat(result.getMessage().getBody(String.class)).isEqualTo("Direct answer
over budget");
+
assertThat(result.getMessage().getHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS,
Long.class)).isEqualTo(120L);
+ }
+
+ @Test
+ void theTraceHoldsOneEntryPerCallOfTheLoop() {
+ Exchange result = template.request("direct:responses-agentic", e ->
e.getIn().setBody("one tool"));
+
+ assertThat(result.getException()).isNull();
+ @SuppressWarnings("unchecked")
+ List<AgenticIterationTrace> trace =
result.getProperty(OpenAIConstants.AGENTIC_TRACE, List.class);
+
+ assertThat(trace).hasSize(2);
+ assertThat(trace.get(0).iteration()).isEqualTo(1);
+ assertThat(trace.get(0).promptTokens()).isEqualTo(10);
+ assertThat(trace.get(0).completionTokens()).isEqualTo(5);
+
assertThat(trace.get(0).toolCalls()).singleElement().satisfies(toolCall -> {
+ assertThat(toolCall.toolName()).isEqualTo("get_weather");
+ assertThat(toolCall.argumentsSummary()).contains("London");
+ assertThat(toolCall.resultSummary()).contains("Sunny in London");
+ assertThat(toolCall.success()).isTrue();
+ });
+ assertThat(trace.get(1).iteration()).isEqualTo(2);
+ assertThat(trace.get(1).toolCalls()).isEmpty();
+ }
+
+ @Test
+ void theLifecycleEventsDescribeTheLoop() {
+ Exchange result = template.request("direct:responses-agentic", e ->
e.getIn().setBody("one tool"));
+
+ assertThat(result.getException()).isNull();
+
assertThat(eventsOfType(OpenAIAgenticLoopStartedEvent.class)).singleElement()
+ .satisfies(started -> {
+ assertThat(started.getToolCount()).isEqualTo(1);
+ assertThat(started.getMaxIterations()).isPositive();
+ });
+
assertThat(eventsOfType(OpenAIAgenticToolCallExecutedEvent.class)).singleElement()
+ .satisfies(toolCall -> {
+
assertThat(toolCall.getToolName()).isEqualTo("get_weather");
+ assertThat(toolCall.getIteration()).isEqualTo(1);
+ assertThat(toolCall.isSuccess()).isTrue();
+ });
+
assertThat(eventsOfType(OpenAIAgenticLoopCompletedEvent.class)).singleElement()
+ .satisfies(completed -> {
+ assertThat(completed.getIterationCount()).isEqualTo(1);
+ assertThat(completed.getTotalTokens()).isEqualTo(30L);
+ assertThat(completed.getStopReason()).isEqualTo("stop");
+ });
+ }
+
+ @Test
+ void theStopReasonNamesTheBudgetAndTheToolThatReturnedDirectly() {
+ template.request("direct:responses-agentic-budget", e ->
e.getIn().setBody("expensive tool call"));
+
+
assertThat(eventsOfType(OpenAIAgenticLoopCompletedEvent.class)).singleElement()
+ .satisfies(completed -> {
+
assertThat(completed.getStopReason()).isEqualTo("token_budget_exceeded");
+ assertThat(completed.getTotalTokens()).isEqualTo(120L);
+ });
+
+ events.clear();
+ Exchange direct = template.request("direct:responses-agentic-direct",
+ e -> e.getIn().setBody("look up an order"));
+
+ assertThat(direct.getException()).isNull();
+ assertThat(direct.getMessage().getBody(String.class)).isEqualTo("Order
42 shipped");
+
assertThat(direct.getMessage().getHeader(OpenAIConstants.MCP_RETURN_DIRECT,
Boolean.class)).isTrue();
+
assertThat(eventsOfType(OpenAIAgenticLoopCompletedEvent.class)).singleElement()
+ .satisfies(completed ->
assertThat(completed.getStopReason()).isEqualTo("return_direct"));
+ }
+
+ private <T extends CamelEvent> List<T> eventsOfType(Class<T> type) {
+ return
events.stream().filter(type::isInstance).map(type::cast).toList();
+ }
+}