Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,7 @@ private ChatUsage collectAggregatedUsage(AgentState agentState) {
int totalInput = 0;
int totalOutput = 0;
int totalCached = 0;
int totalCacheCreation = 0;
double totalTime = 0;
boolean hasUsage = false;
for (Msg msg : agentState.getContext()) {
Expand All @@ -1458,6 +1459,7 @@ private ChatUsage collectAggregatedUsage(AgentState agentState) {
totalInput += usage.getInputTokens();
totalOutput += usage.getOutputTokens();
totalCached += usage.getCachedTokens();
totalCacheCreation += usage.getCacheCreationInputTokens();
totalTime += usage.getTime();
}
}
Expand All @@ -1467,6 +1469,7 @@ private ChatUsage collectAggregatedUsage(AgentState agentState) {
.inputTokens(totalInput)
.outputTokens(totalOutput)
.cachedTokens(totalCached)
.cacheCreationInputTokens(totalCacheCreation)
.time(totalTime)
.build()
: null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public class ReasoningContext {
private int inputTokens = 0;
private int outputTokens = 0;
private int cachedTokens = 0;
private int cacheCreationInputTokens = 0;
private double time = 0;

public ReasoningContext(String agentName) {
Expand Down Expand Up @@ -85,6 +86,7 @@ public List<Msg> processChunk(ChatResponse chunk) {
inputTokens = usage.getInputTokens();
outputTokens = usage.getOutputTokens();
cachedTokens = usage.getCachedTokens();
cacheCreationInputTokens = usage.getCacheCreationInputTokens();
time = usage.getTime();
}

Expand Down Expand Up @@ -169,12 +171,17 @@ public Msg buildFinalMessage() {
// Build metadata with accumulated ChatUsage
Map<String, Object> metadata = new HashMap<>();
ChatUsage chatUsage = null;
if (inputTokens > 0 || outputTokens > 0 || time > 0) {
if (inputTokens > 0
|| outputTokens > 0
|| cachedTokens > 0
|| cacheCreationInputTokens > 0
|| time > 0) {
chatUsage =
ChatUsage.builder()
.inputTokens(inputTokens)
.outputTokens(outputTokens)
.cachedTokens(cachedTokens)
.cacheCreationInputTokens(cacheCreationInputTokens)
.time(time)
.build();
metadata.put(MessageMetadataKeys.CHAT_USAGE, chatUsage);
Expand Down Expand Up @@ -287,11 +294,16 @@ public List<ToolUseBlock> getAllAccumulatedToolCalls() {
* @return ChatUsage with accumulated tokens, or null if no usage data
*/
public ChatUsage getChatUsage() {
if (inputTokens > 0 || outputTokens > 0 || time > 0) {
if (inputTokens > 0
|| outputTokens > 0
|| cachedTokens > 0
|| cacheCreationInputTokens > 0
|| time > 0) {
return ChatUsage.builder()
.inputTokens(inputTokens)
.outputTokens(outputTokens)
.cachedTokens(cachedTokens)
.cacheCreationInputTokens(cacheCreationInputTokens)
.time(time)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
Expand All @@ -62,6 +64,8 @@
public abstract class AbstractBaseFormatter<TReq, TResp, TParams>
implements Formatter<TReq, TResp, TParams> {

protected static final int MAX_PROMPT_CACHE_BREAKPOINTS = 4;

private static final Logger log = LoggerFactory.getLogger(AbstractBaseFormatter.class);

/**
Expand Down Expand Up @@ -160,8 +164,11 @@ protected String formatRoleLabel(MsgRole role) {

/**
* Check if a message should bypass history merging in multiagent formatters.
* Messages with the {@link MessageMetadataKeys#BYPASS_MULTIAGENT_HISTORY_MERGE} flag set to {@code true}
* should be kept as separate messages rather than merged into the conversation history.
* Messages with the {@link MessageMetadataKeys#BYPASS_MULTIAGENT_HISTORY_MERGE} or {@link
* MessageMetadataKeys#CACHE_CONTROL} flag set to either {@code true} or {@code false} should be
* kept as separate messages rather than merged into the conversation history. Preserving
* explicitly marked cache boundaries prevents formatter-level history merging from discarding
* the marker.
*
* @param msg The message to check
* @return true if message should bypass history merging
Expand All @@ -172,7 +179,80 @@ protected boolean shouldBypassHistory(Msg msg) {
}
Object bypassFlag =
msg.getMetadata().get(MessageMetadataKeys.BYPASS_MULTIAGENT_HISTORY_MERGE);
return Boolean.TRUE.equals(bypassFlag);
Object cacheControlFlag = msg.getMetadata().get(MessageMetadataKeys.CACHE_CONTROL);
return Boolean.TRUE.equals(bypassFlag) || cacheControlFlag instanceof Boolean;
}

/**
* Select prompt cache breakpoints while respecting the provider limit.
*
* <p>Explicitly marked items always take priority. When automatic selection is enabled, the
* first cacheable system item and the last cacheable non-system item fill any remaining slots.
* The returned items preserve their original request order.
*
* @param items provider request items to inspect
* @param automatic whether automatic cache breakpoint selection is enabled
* @param explicitlyMarked predicate identifying explicitly marked items
* @param systemItem predicate identifying system items
* @param cacheableItem predicate identifying items eligible for automatic caching
* @param <T> provider request item type
* @return selected cache breakpoint items in request order
* @throws IllegalArgumentException when more than four items are explicitly marked
*/
protected <T> List<T> selectPromptCacheBreakpoints(
List<T> items,
boolean automatic,
Predicate<T> explicitlyMarked,
Predicate<T> systemItem,
Predicate<T> cacheableItem) {
if (items == null || items.isEmpty()) {
return List.of();
}

List<Integer> selectedIndices = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
if (explicitlyMarked.test(items.get(i))) {
selectedIndices.add(i);
}
}

if (selectedIndices.size() > MAX_PROMPT_CACHE_BREAKPOINTS) {
throw new IllegalArgumentException(
"Prompt cache supports at most "
+ MAX_PROMPT_CACHE_BREAKPOINTS
+ " explicit breakpoints, but got "
+ selectedIndices.size());
}

if (automatic && selectedIndices.size() < MAX_PROMPT_CACHE_BREAKPOINTS) {
for (int i = 0; i < items.size(); i++) {
T item = items.get(i);
if (systemItem.test(item) && cacheableItem.test(item)) {
addBreakpointIfAvailable(selectedIndices, i);
break;
}
}

for (int i = items.size() - 1;
i >= 0 && selectedIndices.size() < MAX_PROMPT_CACHE_BREAKPOINTS;
i--) {
T item = items.get(i);
if (!systemItem.test(item) && cacheableItem.test(item)) {
addBreakpointIfAvailable(selectedIndices, i);
break;
}
}
}

Collections.sort(selectedIndices);
return selectedIndices.stream().map(items::get).toList();
}

private void addBreakpointIfAvailable(List<Integer> selectedIndices, int index) {
if (selectedIndices.size() < MAX_PROMPT_CACHE_BREAKPOINTS
&& !selectedIndices.contains(index)) {
selectedIndices.add(index);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,16 @@ private MessageMetadataKeys() {
/**
* Metadata key to explicitly mark a message for prompt caching or non-caching.
*
* <p>When set to {@code true}, the formatter adds <code>cache_control:
* {"type": "ephemeral"}</code> to this message during formatting, unless the message already
* carries a custom <code>cache_control</code> value (e.g. with additional attributes such as
* <code>ttl</code>), in which case it is left untouched. When set to {@code false}, the
* message is explicitly excluded from caching: no <code>cache_control</code> is emitted for it,
* and the automatic cache control strategy configured via
* {@link io.agentscope.core.model.GenerateOptions#getCacheControl()} skips it.
* <p>When set to {@code true}, a supporting formatter encodes a provider-specific cache
* breakpoint at this message, unless an existing custom {@code cache_control} value is present.
* Explicit cache markers remain effective when the automatic strategy configured through
* {@link io.agentscope.core.model.GenerateOptions#getCacheControl()} is disabled or unset. When
* set to {@code false}, the message is explicitly excluded from caching: no provider cache
* marker is emitted for it, and the automatic strategy skips it.
*
* <p>Explicitly marked messages (either {@code true} or {@code false}) take priority over the
* automatic strategy — they will not be overwritten.
* automatic strategy — they will not be overwritten. A request may contain at most four cache
* breakpoints explicitly enabled with {@code true}.
*
* <p><b>Type:</b> Boolean
* <p><b>Example:</b>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,8 @@ public ChatUsage getChatUsage() {
ChatUsage.builder()
.inputTokens(toInt(map.get("inputTokens")))
.outputTokens(toInt(map.get("outputTokens")))
.cachedTokens(toInt(map.get("cachedTokens")))
.cacheCreationInputTokens(toInt(map.get("cacheCreationInputTokens")))
.time(toDouble(map.get("time")))
.build();
metadata.put(MessageMetadataKeys.CHAT_USAGE, chatUsage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
* Represents token usage information for chat completion responses.
*
* <p>This immutable data class tracks the number of tokens used during a chat completion,
* including input tokens (prompt), output tokens (generated response), cached input tokens, and
* execution time.
* including input tokens (prompt), output tokens (generated response), prompt cache reads and
* writes, and execution time.
*/
public class ChatUsage {

private final int inputTokens;
private final int outputTokens;
private final int cachedTokens;
private final int cacheCreationInputTokens;
private final double time;

/**
Expand All @@ -55,15 +56,33 @@ public ChatUsage(int inputTokens, int outputTokens, double time) {
* {@code inputTokens}); {@code 0} when the provider does not report cache information
* @param time the execution time in seconds
*/
public ChatUsage(int inputTokens, int outputTokens, int cachedTokens, double time) {
this(inputTokens, outputTokens, cachedTokens, 0, time);
}

/**
* Creates a new ChatUsage instance with prompt cache read and creation information.
*
* @param inputTokens the total number of tokens used for the input/prompt
* @param outputTokens the number of tokens used for the output/generated response
* @param cachedTokens the number of input tokens served from the prompt cache (a subset of
* {@code inputTokens}); {@code 0} when the provider does not report cache information
* @param cacheCreationInputTokens the number of input tokens written to the prompt cache (a
* subset of {@code inputTokens}); {@code 0} when the provider does not report cache
* creation information
* @param time the execution time in seconds
*/
@JsonCreator
public ChatUsage(
@JsonProperty("inputTokens") int inputTokens,
@JsonProperty("outputTokens") int outputTokens,
@JsonProperty("cachedTokens") int cachedTokens,
@JsonProperty("cacheCreationInputTokens") int cacheCreationInputTokens,
@JsonProperty("time") double time) {
this.inputTokens = inputTokens;
this.outputTokens = outputTokens;
this.cachedTokens = cachedTokens;
this.cacheCreationInputTokens = cacheCreationInputTokens;
this.time = time;
}

Expand Down Expand Up @@ -98,6 +117,18 @@ public int getCachedTokens() {
return cachedTokens;
}

/**
* Gets the number of input tokens written to the prompt cache.
*
* <p>Cache creation tokens are a subset of {@link #getInputTokens()}, not an additional amount.
* Returns {@code 0} when the provider does not report cache creation information.
*
* @return the number of input tokens written to the prompt cache
*/
public int getCacheCreationInputTokens() {
return cacheCreationInputTokens;
}

/**
* Gets the total number of tokens used.
*
Expand Down Expand Up @@ -132,6 +163,7 @@ public static class Builder {
private int inputTokens;
private int outputTokens;
private int cachedTokens;
private int cacheCreationInputTokens;
private double time;

/**
Expand Down Expand Up @@ -168,6 +200,18 @@ public Builder cachedTokens(int cachedTokens) {
return this;
}

/**
* Sets the number of input tokens written to the prompt cache.
*
* @param cacheCreationInputTokens the number of input tokens written to the prompt cache
* (a subset of {@code inputTokens})
* @return this builder instance
*/
public Builder cacheCreationInputTokens(int cacheCreationInputTokens) {
this.cacheCreationInputTokens = cacheCreationInputTokens;
return this;
}

/**
* Sets the execution time.
*
Expand All @@ -185,7 +229,8 @@ public Builder time(double time) {
* @return a new ChatUsage instance
*/
public ChatUsage build() {
return new ChatUsage(inputTokens, outputTokens, cachedTokens, time);
return new ChatUsage(
inputTokens, outputTokens, cachedTokens, cacheCreationInputTokens, time);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -309,15 +309,14 @@ public Long getSeed() {
/**
* Gets whether cache control is enabled for prompt caching.
*
* <p>When true, the formatter will automatically add <code>cache_control:
* {"type": "ephemeral"}</code> to system messages and the last message in the request. This
* enables prompt
* caching on supported providers (e.g., Anthropic, DashScope, OpenAI-compatible APIs) to reduce
* latency and cost.
* <p>When true, a supporting provider enables its automatic prompt-caching strategy. Some
* providers select request breakpoints in the formatter, while others expose a provider-native
* automatic caching mode.
*
* <p>Users can also manually mark individual messages for caching via {@link
* io.agentscope.core.message.MessageMetadataKeys#CACHE_CONTROL} metadata. Manually marked
* messages take priority over the automatic strategy.
* messages take priority over the automatic strategy and remain effective when this option is
* false or unset. Providers currently support at most four explicit breakpoints per request.
*
* @return true if cache control is enabled, false or null if not set
*/
Expand Down Expand Up @@ -780,8 +779,8 @@ public Builder seed(Long seed) {
/**
* Sets whether cache control is enabled for prompt caching.
*
* <p>When true, the formatter will automatically add <code>cache_control:
* {"type": "ephemeral"}</code> to system messages and the last message in the request.
* <p>When true, a supporting provider enables its automatic prompt-caching strategy using
* the request shape defined by that provider.
*
* @param cacheControl true to enable cache control, false to disable
* @return this builder instance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,14 @@ void testStructuredOutputPreservesChatUsage() {
.toJson(
toolInput))
.build()))
.usage(new ChatUsage(100, 50, 1.5))
.usage(
ChatUsage.builder()
.inputTokens(100)
.outputTokens(50)
.cachedTokens(40)
.cacheCreationInputTokens(25)
.time(1.5)
.build())
.build());
} else {
return List.of(
Expand Down Expand Up @@ -435,6 +442,11 @@ void testStructuredOutputPreservesChatUsage() {
assertNotNull(usage, "ChatUsage should be preserved after structured output compression");
assertEquals(100, usage.getInputTokens(), "Input tokens should be preserved");
assertEquals(50, usage.getOutputTokens(), "Output tokens should be preserved");
assertEquals(40, usage.getCachedTokens(), "Cached tokens should be preserved");
assertEquals(
25,
usage.getCacheCreationInputTokens(),
"Cache creation tokens should be preserved");
assertEquals(1.5, usage.getTime(), 0.01, "Time should be preserved");
}

Expand Down
Loading
Loading