mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-10 23:08:45 +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>
185 lines
5.2 KiB
TypeScript
185 lines
5.2 KiB
TypeScript
import { expect, test } from "@rstest/core";
|
|
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
|
|
|
import {
|
|
retainThreadTokenUsagePlaceholder,
|
|
selectContextUsage,
|
|
threadTokenUsageQueryKey,
|
|
threadTokenUsageToTokenUsage,
|
|
} from "@/core/threads/token-usage";
|
|
import type { ThreadTokenUsageResponse } from "@/core/threads/types";
|
|
|
|
test("maps backend thread token usage to UI token usage", () => {
|
|
const response: ThreadTokenUsageResponse = {
|
|
thread_id: "thread-1",
|
|
total_input_tokens: 90,
|
|
total_output_tokens: 60,
|
|
total_tokens: 150,
|
|
total_runs: 2,
|
|
by_model: { unknown: { tokens: 150, runs: 2 } },
|
|
by_caller: {
|
|
lead_agent: 120,
|
|
subagent: 25,
|
|
middleware: 5,
|
|
},
|
|
};
|
|
|
|
expect(threadTokenUsageToTokenUsage(response)).toEqual({
|
|
inputTokens: 90,
|
|
outputTokens: 60,
|
|
totalTokens: 150,
|
|
});
|
|
});
|
|
|
|
test("returns null when backend thread token usage is unavailable", () => {
|
|
expect(threadTokenUsageToTokenUsage(null)).toBeNull();
|
|
expect(threadTokenUsageToTokenUsage(undefined)).toBeNull();
|
|
});
|
|
|
|
test("retains placeholder usage only for the current thread", () => {
|
|
const response: ThreadTokenUsageResponse = {
|
|
thread_id: "thread-1",
|
|
total_input_tokens: 90,
|
|
total_output_tokens: 60,
|
|
total_tokens: 150,
|
|
total_runs: 2,
|
|
by_model: { unknown: { tokens: 150, runs: 2 } },
|
|
by_caller: {
|
|
lead_agent: 120,
|
|
subagent: 25,
|
|
middleware: 5,
|
|
},
|
|
};
|
|
|
|
expect(retainThreadTokenUsagePlaceholder(response, "thread-1")).toBe(
|
|
response,
|
|
);
|
|
expect(
|
|
retainThreadTokenUsagePlaceholder(response, "thread-2"),
|
|
).toBeUndefined();
|
|
expect(retainThreadTokenUsagePlaceholder(null, undefined)).toBeUndefined();
|
|
});
|
|
|
|
test("query observer keeps same-thread data but drops it while a new thread is pending", async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
const threadA: ThreadTokenUsageResponse = {
|
|
thread_id: "thread-a",
|
|
total_input_tokens: 90,
|
|
total_output_tokens: 60,
|
|
total_tokens: 150,
|
|
total_runs: 2,
|
|
by_model: { unknown: { tokens: 150, runs: 2 } },
|
|
by_caller: { lead_agent: 120, subagent: 25, middleware: 5 },
|
|
};
|
|
const threadB: ThreadTokenUsageResponse = {
|
|
...threadA,
|
|
thread_id: "thread-b",
|
|
total_tokens: 200,
|
|
};
|
|
let queryResult = Promise.resolve(threadA);
|
|
let resolveRefresh: (value: ThreadTokenUsageResponse) => void = () =>
|
|
undefined;
|
|
let resolveThreadB: (value: ThreadTokenUsageResponse) => void = () =>
|
|
undefined;
|
|
const observer = new QueryObserver<ThreadTokenUsageResponse | null>(
|
|
queryClient,
|
|
{
|
|
queryKey: threadTokenUsageQueryKey("thread-a"),
|
|
queryFn: () => queryResult,
|
|
placeholderData: (previous) =>
|
|
retainThreadTokenUsagePlaceholder(previous, "thread-a"),
|
|
},
|
|
);
|
|
const unsubscribe = observer.subscribe(() => undefined);
|
|
|
|
try {
|
|
await observer.refetch();
|
|
expect(observer.getCurrentResult().data).toBe(threadA);
|
|
|
|
queryResult = new Promise((resolve) => {
|
|
resolveRefresh = resolve;
|
|
});
|
|
const sameThreadRefetch = observer.refetch();
|
|
expect(observer.getCurrentResult().data).toBe(threadA);
|
|
resolveRefresh(threadA);
|
|
await sameThreadRefetch;
|
|
|
|
const pendingThreadB = new Promise<ThreadTokenUsageResponse>((resolve) => {
|
|
resolveThreadB = resolve;
|
|
});
|
|
observer.setOptions({
|
|
queryKey: threadTokenUsageQueryKey("thread-b"),
|
|
queryFn: () => pendingThreadB,
|
|
retry: false,
|
|
placeholderData: (previous) =>
|
|
retainThreadTokenUsagePlaceholder(previous, "thread-b"),
|
|
});
|
|
expect(observer.getCurrentResult().data).toBeUndefined();
|
|
expect(observer.getCurrentResult().isPlaceholderData).toBe(false);
|
|
|
|
resolveThreadB(threadB);
|
|
await observer.refetch();
|
|
expect(observer.getCurrentResult().data).toBe(threadB);
|
|
} finally {
|
|
resolveRefresh(threadA);
|
|
resolveThreadB(threadB);
|
|
unsubscribe();
|
|
queryClient.clear();
|
|
}
|
|
});
|
|
|
|
const _baseResponse = {
|
|
thread_id: "thread-1",
|
|
total_input_tokens: 0,
|
|
total_output_tokens: 0,
|
|
total_tokens: 0,
|
|
total_runs: 0,
|
|
by_model: {},
|
|
by_caller: { lead_agent: 0, subagent: 0, middleware: 0 },
|
|
} satisfies ThreadTokenUsageResponse;
|
|
|
|
test("selectContextUsage projects the backend block to UI shape", () => {
|
|
const response: ThreadTokenUsageResponse = {
|
|
..._baseResponse,
|
|
context_usage: {
|
|
token_count: 350,
|
|
max_context_tokens: 1000,
|
|
percentage: 35,
|
|
},
|
|
};
|
|
|
|
expect(selectContextUsage(response)).toEqual({
|
|
tokenCount: 350,
|
|
maxContextTokens: 1000,
|
|
percentage: 35,
|
|
});
|
|
});
|
|
|
|
test("selectContextUsage preserves nullable capacity and percentage", () => {
|
|
const response: ThreadTokenUsageResponse = {
|
|
..._baseResponse,
|
|
context_usage: {
|
|
token_count: 200,
|
|
max_context_tokens: null,
|
|
percentage: null,
|
|
},
|
|
};
|
|
|
|
expect(selectContextUsage(response)).toEqual({
|
|
tokenCount: 200,
|
|
maxContextTokens: null,
|
|
percentage: null,
|
|
});
|
|
});
|
|
|
|
test("selectContextUsage returns null when context_usage is missing", () => {
|
|
expect(selectContextUsage(_baseResponse)).toBeNull();
|
|
expect(
|
|
selectContextUsage({ ..._baseResponse, context_usage: null }),
|
|
).toBeNull();
|
|
expect(selectContextUsage(null)).toBeNull();
|
|
expect(selectContextUsage(undefined)).toBeNull();
|
|
});
|