fix: report current context usage in token indicator (#9255)

This commit is contained in:
Haoran Xu
2026-07-13 21:21:17 +08:00
committed by GitHub
parent d0e5e68c55
commit cc0ec5e5c5
4 changed files with 110 additions and 5 deletions

View File

@@ -18,6 +18,8 @@ class AgentResponse:
@dataclass
class AgentStats:
token_usage: TokenUsage = field(default_factory=TokenUsage)
current_context_tokens: int = 0
"""Input tokens sent in the most recent LLM request."""
start_time: float = 0.0
end_time: float = 0.0
time_to_first_token: float = 0.0
@@ -29,6 +31,7 @@ class AgentStats:
def to_dict(self) -> dict:
return {
"token_usage": self.token_usage.__dict__,
"current_context_tokens": self.current_context_tokens,
"start_time": self.start_time,
"end_time": self.end_time,
"time_to_first_token": self.time_to_first_token,

View File

@@ -770,9 +770,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
continue
llm_resp_result = llm_response
if not llm_response.is_chunk and llm_response.usage:
# only count the token usage of the final response for computation purpose
# Chunk responses have already continued above. A missing usage report
# means the latest context occupancy is unknown.
self.stats.current_context_tokens = 0
if llm_response.usage:
# Keep cumulative usage for billing and expose the latest request
# input separately for context-window occupancy displays.
self.stats.token_usage += llm_response.usage
self.stats.current_context_tokens = llm_response.usage.input
if self.req.conversation:
self.req.conversation.token_usage = llm_response.usage.total
break # got final response

View File

@@ -867,11 +867,16 @@ const currentTokenMetadata = computed(() => {
const model = currentTokenProvider.value?.model;
return model ? tokenModelMetadata.value[model] || null : null;
});
const latestTokenUsageTotal = computed(() => {
const latestContextTokens = computed(() => {
for (let index = activeMessages.value.length - 1; index >= 0; index -= 1) {
const message = activeMessages.value[index];
if (isUserMessage(message)) continue;
const usage = message.content?.agentStats?.token_usage;
const stats = message.content?.agentStats;
if (!stats) continue;
if (stats.current_context_tokens != null) {
return readTokenCount(stats.current_context_tokens);
}
const usage = stats.token_usage;
if (!usage) continue;
return (
readTokenCount(usage.input_other) +
@@ -882,7 +887,7 @@ const latestTokenUsageTotal = computed(() => {
return 0;
});
const tokenUsageIndicator = computed(() => {
const used = latestTokenUsageTotal.value;
const used = latestContextTokens.value;
const limit = contextLimit(currentTokenProvider.value, currentTokenMetadata.value);
if (used <= 0 || limit <= 0) return null;

View File

@@ -142,6 +142,41 @@ class MockMixedContentToolExecutor:
return generator()
class VaryingUsageProvider(MockProvider):
"""Return distinct token usage values for each tool-loop request."""
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
usage = TokenUsage(
input_other=self.call_count * 100,
input_cached=self.call_count * 10,
output=self.call_count,
)
if self.call_count == 1:
return LLMResponse(
role="assistant",
tools_call_name=["test_tool"],
tools_call_args=[{"query": "test"}],
tools_call_ids=["call_varying_usage"],
usage=usage,
)
return LLMResponse(
role="assistant",
completion_text="final",
usage=usage,
)
class MissingFinalUsageProvider(VaryingUsageProvider):
"""Omit usage from the final response after reporting an earlier request."""
async def text_chat(self, **kwargs) -> LLMResponse:
response = await super().text_chat(**kwargs)
if self.call_count == 2:
response.usage = None
return response
class MockFailingProvider(MockProvider):
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
@@ -587,6 +622,63 @@ async def test_normal_completion_without_max_step(
assert runner.req.func_tool is not None, "正常完成时工具不应该被禁用"
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_stats_separate_latest_context_from_cumulative_usage(
runner, provider_request, mock_tool_executor, mock_hooks, streaming
):
"""Context occupancy uses the latest input while usage remains cumulative."""
provider = VaryingUsageProvider()
await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=streaming,
)
async for _ in runner.step_until_done(3):
pass
assert provider.call_count == 2
assert runner.stats.token_usage == TokenUsage(
input_other=300,
input_cached=30,
output=3,
)
assert runner.stats.current_context_tokens == 220
assert runner.stats.to_dict()["current_context_tokens"] == 220
@pytest.mark.asyncio
@pytest.mark.parametrize("streaming", [False, True])
async def test_stats_clear_current_context_when_latest_usage_is_missing(
runner, provider_request, mock_tool_executor, mock_hooks, streaming
):
"""Do not expose stale context occupancy when the latest usage is unknown."""
provider = MissingFinalUsageProvider()
await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=streaming,
)
async for _ in runner.step_until_done(3):
pass
assert runner.stats.token_usage == TokenUsage(
input_other=100,
input_cached=10,
output=1,
)
assert runner.stats.current_context_tokens == 0
assert runner.stats.to_dict()["current_context_tokens"] == 0
@pytest.mark.asyncio
async def test_max_step_with_streaming(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks