fix: use backend thread token usage for header total (#2800)
* fix: use backend thread token usage for header total * Refactor thread token usage fetch
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { accumulateUsage } from "@/core/messages/usage";
|
||||
import { accumulateUsage, selectHeaderTokenUsage } from "@/core/messages/usage";
|
||||
import {
|
||||
getAssistantTurnUsageMessages,
|
||||
getMessageGroups,
|
||||
@@ -79,3 +79,86 @@ test("keeps header and per-turn aggregation consistent for duplicated UI groups"
|
||||
totalTokens: 27,
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers backend thread usage for header totals", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "ai-visible",
|
||||
type: "ai",
|
||||
content: "Visible answer",
|
||||
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(
|
||||
selectHeaderTokenUsage({
|
||||
backendUsage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
messages,
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
});
|
||||
});
|
||||
|
||||
test("adds current in-flight message usage to backend header totals", () => {
|
||||
const completedMessages = [
|
||||
{
|
||||
id: "ai-completed",
|
||||
type: "ai",
|
||||
content: "Completed answer",
|
||||
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
{
|
||||
id: "ai-pending",
|
||||
type: "ai",
|
||||
content: "Streaming answer",
|
||||
usage_metadata: { input_tokens: 4, output_tokens: 6, total_tokens: 10 },
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(
|
||||
selectHeaderTokenUsage({
|
||||
backendUsage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
messages: completedMessages,
|
||||
pendingMessages: [completedMessages[1]!],
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 104,
|
||||
outputTokens: 56,
|
||||
totalTokens: 160,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to visible messages when backend usage is unavailable or zero", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "ai-visible",
|
||||
type: "ai",
|
||||
content: "Visible answer",
|
||||
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(
|
||||
selectHeaderTokenUsage({
|
||||
backendUsage: null,
|
||||
messages,
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
});
|
||||
expect(
|
||||
selectHeaderTokenUsage({
|
||||
backendUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
||||
messages,
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const fetchWithAuth = vi.fn();
|
||||
|
||||
vi.mock("@/core/api/fetcher", () => ({
|
||||
fetch: fetchWithAuth,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuth.mockReset();
|
||||
});
|
||||
|
||||
test("fetchThreadTokenUsage uses shared auth fetch without JSON GET headers", async () => {
|
||||
fetchWithAuth.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
thread_id: "thread-1",
|
||||
total_input_tokens: 3,
|
||||
total_output_tokens: 4,
|
||||
total_tokens: 7,
|
||||
total_runs: 1,
|
||||
by_model: { unknown: { tokens: 7, runs: 1 } },
|
||||
by_caller: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const { fetchThreadTokenUsage } = await import("@/core/threads/api");
|
||||
|
||||
await expect(fetchThreadTokenUsage("thread-1")).resolves.toMatchObject({
|
||||
thread_id: "thread-1",
|
||||
total_tokens: 7,
|
||||
});
|
||||
|
||||
expect(fetchWithAuth).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/threads/thread-1/token-usage"),
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("fetchThreadTokenUsage returns null for unavailable token usage", async () => {
|
||||
fetchWithAuth.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const { fetchThreadTokenUsage } = await import("@/core/threads/api");
|
||||
|
||||
await expect(fetchThreadTokenUsage("thread-1")).resolves.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { 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();
|
||||
});
|
||||
Reference in New Issue
Block a user