mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-08 22:08:46 +00:00
* feat: show real-time context window usage in chat UI (#3125) Adds a `context_usage` block to `GET /api/threads/{id}/token-usage` (token count from the live checkpoint, the thread model's `context_window`, and a percentage), introduces a new `ModelConfig.context_window` distinct from the per-call `max_tokens` output cap, and surfaces the percentage in the chat header — inside `TokenUsageIndicator` when token-usage tracking is on, or as a standalone badge when it's off so context capacity stays visible independent of cost tracking. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: per-category breakdown for context window usage Replace the single-number context_usage payload with a Claude-Code-style breakdown — messages, system prompt, skills, system/MCP tools (active + deferred), custom agents, memory injection, autocompact buffer, and free space — and surface it in the chat UI with a segmented progress bar and per-row table. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(config): document context_window across model examples Add `context_window` to every example model in config.example.yaml so the new chat-UI "% context used" indicator works out of the box for whichever example a user adopts. Each value is the published default at the time of writing; users are pointed at the official model spec to verify. Bumps config_version to 11 so `make config-upgrade` flags outdated user configs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: ruff format (line-length 240) No behavior change — collapses two multi-line expressions that fit on one line under the project's 240-char limit. Picked up by `make format`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * review: address Copilot bot comments on #3183 - token-usage-indicator: switch `{contextPercentage && (...)}` to an explicit `!= null` check. (The string `"0"` is actually truthy in JS so the original code wasn't buggy, but the explicit check is clearer.) - context-usage-breakdown: drop the `useMemo` around segments/totals — the computation is O(n) over a handful of rows and the previous memo deps omitted `t.contextUsage.categories`, so the bar's tooltips/aria-labels could stay in the old language after a locale switch. - context_usage._split_tools: snapshot MCP names from `get_cached_mcp_tools()` directly instead of re-reading `extensions_config.json` after `get_available_tools()` already loaded it. Removes redundant file I/O on every `/token-usage` poll. (`get_available_tools()` still emits its own INFO logs — silencing those is out of scope here.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style(frontend): prettier --write context-usage-breakdown CI's `pnpm format` (prettier --check) caught two lines previously formatted by hand. Collapses one comma to fit on one line; no behavior change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(gateway): correct context-usage breakdown + add exact token counting The context-usage indicator shipped two bugs that silently zeroed whole breakdown rows (both caught by try/except, so the feature looked alive but produced wrong numbers): 1. _count_system_prompt passed app_config= to get_deferred_tools_prompt_section, which only accepts deferred_names -> TypeError swallowed -> system_prompt row always 0, and used_tokens/percentage undercounted by the full prompt. Also subtracted the deferred section twice (the rendered prompt already excluded it). Fix: derive deferred names deterministically and pass them to apply_prompt_template; drop the redundant subtraction. 2. _split_tools imported a non-existent get_deferred_registry -> ImportError swallowed -> all four tool-category rows always 0. Fix: classify via the public is_mcp_tool predicate + tool_search.enabled (mirrors build_deferred_tool_setup); the MCP tag is set by get_available_tools. Added token_usage.counting (approximate|exact). 'exact' routes text/schema/ message counting through the model tokenizer (tiktoken cl100k_base) via the existing memory-module machinery (lazy load + cache + cooldown + CJK-aware fallback), so CJK-heavy threads stop being undercounted by chars//4. Regression + e2e tests added; 6621 backend tests pass. * fix(gateway): harden context usage accounting * fix(gateway): count promoted MCP tools as active in context usage Promoted tools (deferred MCP tools the thread has fetched via tool_search) have their full schema bound on every subsequent turn by DeferredToolFilterMiddleware, so they consume context like any active tool. The breakdown previously left them in the reserved *_deferred rows, under- counting the thread's used_tokens. Classification now treats a tool as deferred only when tool_search is enabled, it is MCP-sourced, AND it has not been promoted. The promoted set is read from the checkpoint's channel_values and scoped by catalog hash — matching the runtime middleware, so a stale promotion from MCP-config drift cannot inflate the active count. The static system prompt still lists all deferred tool names (promotions only affect schema binding, not the prompt), so _count_system_prompt's deferred rendering is intentionally left unchanged. 8 new tests cover classification, catalog-hash scoping (match / drift / compute-failure / malformed), and checkpoint extraction. * fix(context): address review feedback * fix(context): count structured message payloads * fix(context): harden usage accounting * fix(config): bump schema for context usage fields * refactor: narrow context usage to core indicator --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
183 lines
6.2 KiB
Python
183 lines
6.2 KiB
Python
"""Tests for thread-level token usage and context-window usage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from _router_auth_helpers import make_authed_test_app
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.gateway import context_usage
|
|
from app.gateway.routers import thread_runs
|
|
|
|
|
|
def _aggregate_result() -> dict:
|
|
return {
|
|
"total_tokens": 150,
|
|
"total_input_tokens": 90,
|
|
"total_output_tokens": 60,
|
|
"total_runs": 2,
|
|
"by_model": {"unknown": {"tokens": 150, "runs": 2}},
|
|
"by_caller": {
|
|
"lead_agent": 120,
|
|
"subagent": 25,
|
|
"middleware": 5,
|
|
},
|
|
}
|
|
|
|
|
|
def _make_run_store(*, model_name: str | None = None) -> MagicMock:
|
|
run_store = MagicMock()
|
|
run_store.aggregate_tokens_by_thread = AsyncMock(return_value=_aggregate_result())
|
|
runs = [{"model_name": model_name}] if model_name else []
|
|
run_store.list_by_thread = AsyncMock(return_value=runs)
|
|
return run_store
|
|
|
|
|
|
def _make_app(run_store: MagicMock):
|
|
app = make_authed_test_app()
|
|
app.include_router(thread_runs.router)
|
|
app.state.run_store = run_store
|
|
return app
|
|
|
|
|
|
def test_thread_token_usage_returns_stable_shape(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
run_store = _make_run_store()
|
|
build_context_usage = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(thread_runs, "build_context_usage", build_context_usage)
|
|
app = _make_app(run_store)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/threads/thread-1/token-usage")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"thread_id": "thread-1",
|
|
**_aggregate_result(),
|
|
"context_usage": None,
|
|
}
|
|
run_store.aggregate_tokens_by_thread.assert_awaited_once_with("thread-1")
|
|
build_context_usage.assert_awaited_once()
|
|
|
|
|
|
def test_thread_token_usage_can_include_active_runs(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
run_store = _make_run_store()
|
|
build_context_usage = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(thread_runs, "build_context_usage", build_context_usage)
|
|
app = _make_app(run_store)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/threads/thread-1/token-usage?include_active=true")
|
|
|
|
assert response.status_code == 200
|
|
run_store.aggregate_tokens_by_thread.assert_awaited_once_with("thread-1", include_active=True)
|
|
|
|
|
|
def test_thread_token_usage_serializes_context_percentage(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
run_store = _make_run_store()
|
|
monkeypatch.setattr(
|
|
thread_runs,
|
|
"build_context_usage",
|
|
AsyncMock(
|
|
return_value={
|
|
"token_count": 350,
|
|
"max_context_tokens": 1000,
|
|
"percentage": 35.0,
|
|
}
|
|
),
|
|
)
|
|
app = _make_app(run_store)
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get("/api/threads/thread-1/token-usage")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["context_usage"] == {
|
|
"token_count": 350,
|
|
"max_context_tokens": 1000,
|
|
"percentage": 35.0,
|
|
}
|
|
|
|
|
|
def test_build_context_usage_payload_computes_percentage() -> None:
|
|
assert context_usage.build_context_usage_payload(token_count=350, max_context_tokens=1000) == {
|
|
"token_count": 350,
|
|
"max_context_tokens": 1000,
|
|
"percentage": 35.0,
|
|
}
|
|
|
|
|
|
def test_build_context_usage_payload_handles_unknown_capacity() -> None:
|
|
assert context_usage.build_context_usage_payload(token_count=350, max_context_tokens=None) == {
|
|
"token_count": 350,
|
|
"max_context_tokens": None,
|
|
"percentage": None,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_thread_model_prefers_latest_run() -> None:
|
|
run_store = _make_run_store(model_name="thread-model")
|
|
app_config = SimpleNamespace(models=[SimpleNamespace(name="fallback-model")])
|
|
|
|
assert await context_usage._resolve_thread_model_name(run_store, "thread-1", app_config) == "thread-model"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_thread_model_falls_back_to_first_configured_model() -> None:
|
|
run_store = _make_run_store()
|
|
app_config = SimpleNamespace(models=[SimpleNamespace(name="fallback-model")])
|
|
|
|
assert await context_usage._resolve_thread_model_name(run_store, "thread-1", app_config) == "fallback-model"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_usage_counts_materialized_messages(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
messages = [SimpleNamespace(content="hello")]
|
|
snapshot = SimpleNamespace(values={"messages": messages})
|
|
accessor = SimpleNamespace(aget=AsyncMock(return_value=snapshot))
|
|
monkeypatch.setattr(
|
|
context_usage,
|
|
"build_thread_checkpoint_state_accessor",
|
|
AsyncMock(return_value=(accessor, {"configurable": {"thread_id": "thread-1"}})),
|
|
)
|
|
model_config = SimpleNamespace(context_window=1000)
|
|
app_config = SimpleNamespace(
|
|
models=[SimpleNamespace(name="fallback-model")],
|
|
get_model_config=lambda name: model_config if name == "thread-model" else None,
|
|
)
|
|
monkeypatch.setattr(context_usage, "get_config", lambda: app_config)
|
|
monkeypatch.setattr(context_usage, "_count_messages_approximately", lambda value: 250 if value == messages else 0)
|
|
|
|
result = await context_usage.build_context_usage(
|
|
request=SimpleNamespace(app=SimpleNamespace()),
|
|
thread_id="thread-1",
|
|
run_store=_make_run_store(model_name="thread-model"),
|
|
)
|
|
|
|
assert result == {
|
|
"token_count": 250,
|
|
"max_context_tokens": 1000,
|
|
"percentage": 25.0,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_usage_returns_none_when_checkpoint_read_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
context_usage,
|
|
"build_thread_checkpoint_state_accessor",
|
|
AsyncMock(side_effect=RuntimeError("checkpoint unavailable")),
|
|
)
|
|
monkeypatch.setattr(context_usage, "get_config", lambda: SimpleNamespace())
|
|
|
|
result = await context_usage.build_context_usage(
|
|
request=SimpleNamespace(app=SimpleNamespace()),
|
|
thread_id="thread-1",
|
|
run_store=_make_run_store(),
|
|
)
|
|
|
|
assert result is None
|