mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
feat: show token usage per assistant response (#2270)
* feat: show token usage per assistant response * fix: align client models response with token usage * fix: address token usage review feedback * docs: clarify token usage config example --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
0e16a7fe55
commit
105db00987
@ -17,10 +17,17 @@ class ModelResponse(BaseModel):
|
|||||||
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsageResponse(BaseModel):
|
||||||
|
"""Token usage display configuration."""
|
||||||
|
|
||||||
|
enabled: bool = Field(default=False, description="Whether token usage display is enabled")
|
||||||
|
|
||||||
|
|
||||||
class ModelsListResponse(BaseModel):
|
class ModelsListResponse(BaseModel):
|
||||||
"""Response model for listing all models."""
|
"""Response model for listing all models."""
|
||||||
|
|
||||||
models: list[ModelResponse]
|
models: list[ModelResponse]
|
||||||
|
token_usage: TokenUsageResponse
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@ -36,7 +43,7 @@ async def list_models() -> ModelsListResponse:
|
|||||||
excluding sensitive fields like API keys and internal configuration.
|
excluding sensitive fields like API keys and internal configuration.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of all configured models with their metadata.
|
A list of all configured models with their metadata and token usage display settings.
|
||||||
|
|
||||||
Example Response:
|
Example Response:
|
||||||
```json
|
```json
|
||||||
@ -44,17 +51,24 @@ async def list_models() -> ModelsListResponse:
|
|||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
"name": "gpt-4",
|
"name": "gpt-4",
|
||||||
|
"model": "gpt-4",
|
||||||
"display_name": "GPT-4",
|
"display_name": "GPT-4",
|
||||||
"description": "OpenAI GPT-4 model",
|
"description": "OpenAI GPT-4 model",
|
||||||
"supports_thinking": false
|
"supports_thinking": false,
|
||||||
|
"supports_reasoning_effort": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "claude-3-opus",
|
"name": "claude-3-opus",
|
||||||
|
"model": "claude-3-opus",
|
||||||
"display_name": "Claude 3 Opus",
|
"display_name": "Claude 3 Opus",
|
||||||
"description": "Anthropic Claude 3 Opus model",
|
"description": "Anthropic Claude 3 Opus model",
|
||||||
"supports_thinking": true
|
"supports_thinking": true,
|
||||||
|
"supports_reasoning_effort": false
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"token_usage": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
@ -70,7 +84,10 @@ async def list_models() -> ModelsListResponse:
|
|||||||
)
|
)
|
||||||
for model in config.models
|
for model in config.models
|
||||||
]
|
]
|
||||||
return ModelsListResponse(models=models)
|
return ModelsListResponse(
|
||||||
|
models=models,
|
||||||
|
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@ -722,6 +722,10 @@ class DeerFlowClient:
|
|||||||
Dict with "models" key containing list of model info dicts,
|
Dict with "models" key containing list of model info dicts,
|
||||||
matching the Gateway API ``ModelsListResponse`` schema.
|
matching the Gateway API ``ModelsListResponse`` schema.
|
||||||
"""
|
"""
|
||||||
|
token_usage_enabled = getattr(getattr(self._app_config, "token_usage", None), "enabled", False)
|
||||||
|
if not isinstance(token_usage_enabled, bool):
|
||||||
|
token_usage_enabled = False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"models": [
|
"models": [
|
||||||
{
|
{
|
||||||
@ -733,7 +737,8 @@ class DeerFlowClient:
|
|||||||
"supports_reasoning_effort": getattr(model, "supports_reasoning_effort", False),
|
"supports_reasoning_effort": getattr(model, "supports_reasoning_effort", False),
|
||||||
}
|
}
|
||||||
for model in self._app_config.models
|
for model in self._app_config.models
|
||||||
]
|
],
|
||||||
|
"token_usage": {"enabled": token_usage_enabled},
|
||||||
}
|
}
|
||||||
|
|
||||||
def list_skills(self, enabled_only: bool = False) -> dict:
|
def list_skills(self, enabled_only: bool = False) -> dict:
|
||||||
|
|||||||
@ -38,6 +38,7 @@ def mock_app_config():
|
|||||||
|
|
||||||
config = MagicMock()
|
config = MagicMock()
|
||||||
config.models = [model]
|
config.models = [model]
|
||||||
|
config.token_usage.enabled = False
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@ -107,6 +108,7 @@ class TestConfigQueries:
|
|||||||
def test_list_models(self, client):
|
def test_list_models(self, client):
|
||||||
result = client.list_models()
|
result = client.list_models()
|
||||||
assert "models" in result
|
assert "models" in result
|
||||||
|
assert result["token_usage"] == {"enabled": False}
|
||||||
assert len(result["models"]) == 1
|
assert len(result["models"]) == 1
|
||||||
assert result["models"][0]["name"] == "test-model"
|
assert result["models"][0]["name"] == "test-model"
|
||||||
# Verify Gateway-aligned fields are present
|
# Verify Gateway-aligned fields are present
|
||||||
@ -2196,7 +2198,9 @@ class TestGatewayConformance:
|
|||||||
model.display_name = "Test Model"
|
model.display_name = "Test Model"
|
||||||
model.description = "A test model"
|
model.description = "A test model"
|
||||||
model.supports_thinking = False
|
model.supports_thinking = False
|
||||||
|
model.supports_reasoning_effort = False
|
||||||
mock_app_config.models = [model]
|
mock_app_config.models = [model]
|
||||||
|
mock_app_config.token_usage.enabled = True
|
||||||
|
|
||||||
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
|
with patch("deerflow.client.get_app_config", return_value=mock_app_config):
|
||||||
client = DeerFlowClient()
|
client = DeerFlowClient()
|
||||||
@ -2206,6 +2210,7 @@ class TestGatewayConformance:
|
|||||||
assert len(parsed.models) == 1
|
assert len(parsed.models) == 1
|
||||||
assert parsed.models[0].name == "test-model"
|
assert parsed.models[0].name == "test-model"
|
||||||
assert parsed.models[0].model == "gpt-test"
|
assert parsed.models[0].model == "gpt-test"
|
||||||
|
assert parsed.token_usage.enabled is True
|
||||||
|
|
||||||
def test_get_model(self, mock_app_config):
|
def test_get_model(self, mock_app_config):
|
||||||
model = MagicMock()
|
model = MagicMock()
|
||||||
|
|||||||
@ -21,10 +21,11 @@ config_version: 7
|
|||||||
log_level: info
|
log_level: info
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Token Usage Tracking
|
# Token Usage
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Track LLM token usage per model call (input/output/total tokens)
|
# Enable token usage collection and display.
|
||||||
# Logs at info level via TokenUsageMiddleware
|
# When enabled, DeerFlow records input/output/total tokens per model call
|
||||||
|
# and shows usage metadata in the workspace UI when providers return it.
|
||||||
token_usage:
|
token_usage:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
|
|||||||
import { Tooltip } from "@/components/workspace/tooltip";
|
import { Tooltip } from "@/components/workspace/tooltip";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
import { useThreadSettings } from "@/core/settings";
|
import { useThreadSettings } from "@/core/settings";
|
||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
@ -44,6 +45,7 @@ export default function AgentChatPage() {
|
|||||||
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
const { threadId, setThreadId, isNewThread, setIsNewThread } =
|
||||||
useThreadChat();
|
useThreadChat();
|
||||||
const [settings, setSettings] = useThreadSettings(threadId);
|
const [settings, setSettings] = useThreadSettings(threadId);
|
||||||
|
const { tokenUsageEnabled } = useModels();
|
||||||
|
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
@ -128,7 +130,10 @@ export default function AgentChatPage() {
|
|||||||
<PlusSquare /> {t.agents.newChat}
|
<PlusSquare /> {t.agents.newChat}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<TokenUsageIndicator messages={thread.messages} />
|
<TokenUsageIndicator
|
||||||
|
enabled={tokenUsageEnabled}
|
||||||
|
messages={thread.messages}
|
||||||
|
/>
|
||||||
<ExportTrigger threadId={threadId} />
|
<ExportTrigger threadId={threadId} />
|
||||||
<ArtifactTrigger />
|
<ArtifactTrigger />
|
||||||
</div>
|
</div>
|
||||||
@ -141,6 +146,7 @@ export default function AgentChatPage() {
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
paddingBottom={messageListPaddingBottom}
|
paddingBottom={messageListPaddingBottom}
|
||||||
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
|||||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||||
import { Welcome } from "@/components/workspace/welcome";
|
import { Welcome } from "@/components/workspace/welcome";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { useModels } from "@/core/models/hooks";
|
||||||
import { useNotification } from "@/core/notification/hooks";
|
import { useNotification } from "@/core/notification/hooks";
|
||||||
import { useThreadSettings } from "@/core/settings";
|
import { useThreadSettings } from "@/core/settings";
|
||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
@ -36,6 +37,7 @@ export default function ChatPage() {
|
|||||||
useThreadChat();
|
useThreadChat();
|
||||||
const [settings, setSettings] = useThreadSettings(threadId);
|
const [settings, setSettings] = useThreadSettings(threadId);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const { tokenUsageEnabled } = useModels();
|
||||||
useSpecificChatMode();
|
useSpecificChatMode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -103,7 +105,10 @@ export default function ChatPage() {
|
|||||||
<ThreadTitle threadId={threadId} thread={thread} />
|
<ThreadTitle threadId={threadId} thread={thread} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TokenUsageIndicator messages={thread.messages} />
|
<TokenUsageIndicator
|
||||||
|
enabled={tokenUsageEnabled}
|
||||||
|
messages={thread.messages}
|
||||||
|
/>
|
||||||
<ExportTrigger threadId={threadId} />
|
<ExportTrigger threadId={threadId} />
|
||||||
<ArtifactTrigger />
|
<ArtifactTrigger />
|
||||||
</div>
|
</div>
|
||||||
@ -115,6 +120,7 @@ export default function ChatPage() {
|
|||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
thread={thread}
|
thread={thread}
|
||||||
paddingBottom={messageListPaddingBottom}
|
paddingBottom={messageListPaddingBottom}
|
||||||
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||||
|
|||||||
@ -38,17 +38,20 @@ import { cn } from "@/lib/utils";
|
|||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
import { MessageTokenUsage } from "./message-token-usage";
|
||||||
|
|
||||||
export function MessageListItem({
|
export function MessageListItem({
|
||||||
className,
|
className,
|
||||||
message,
|
message,
|
||||||
isLoading,
|
isLoading,
|
||||||
threadId,
|
threadId,
|
||||||
|
tokenUsageEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
tokenUsageEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
return (
|
return (
|
||||||
@ -61,6 +64,7 @@ export function MessageListItem({
|
|||||||
message={message}
|
message={message}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
/>
|
/>
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<MessageToolbar
|
<MessageToolbar
|
||||||
@ -119,11 +123,13 @@ function MessageContent_({
|
|||||||
message,
|
message,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
threadId,
|
threadId,
|
||||||
|
tokenUsageEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
tokenUsageEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
@ -201,6 +207,11 @@ function MessageContent_({
|
|||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
|
<MessageTokenUsage
|
||||||
|
enabled={tokenUsageEnabled}
|
||||||
|
isLoading={isLoading}
|
||||||
|
message={message}
|
||||||
|
/>
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -238,6 +249,11 @@ function MessageContent_({
|
|||||||
className="my-3"
|
className="my-3"
|
||||||
components={components}
|
components={components}
|
||||||
/>
|
/>
|
||||||
|
<MessageTokenUsage
|
||||||
|
enabled={tokenUsageEnabled}
|
||||||
|
isLoading={isLoading}
|
||||||
|
message={message}
|
||||||
|
/>
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
hasContent,
|
hasContent,
|
||||||
hasPresentFiles,
|
hasPresentFiles,
|
||||||
hasReasoning,
|
hasReasoning,
|
||||||
|
hasToolCalls,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import type { Subtask } from "@/core/tasks";
|
import type { Subtask } from "@/core/tasks";
|
||||||
@ -26,6 +27,7 @@ import { StreamingIndicator } from "../streaming-indicator";
|
|||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { MessageGroup } from "./message-group";
|
import { MessageGroup } from "./message-group";
|
||||||
import { MessageListItem } from "./message-list-item";
|
import { MessageListItem } from "./message-list-item";
|
||||||
|
import { MessageTokenUsageList } from "./message-token-usage";
|
||||||
import { MessageListSkeleton } from "./skeleton";
|
import { MessageListSkeleton } from "./skeleton";
|
||||||
import { SubtaskCard } from "./subtask-card";
|
import { SubtaskCard } from "./subtask-card";
|
||||||
|
|
||||||
@ -37,11 +39,13 @@ export function MessageList({
|
|||||||
threadId,
|
threadId,
|
||||||
thread,
|
thread,
|
||||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||||
|
tokenUsageEnabled = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
paddingBottom?: number;
|
paddingBottom?: number;
|
||||||
|
tokenUsageEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
@ -64,6 +68,7 @@ export function MessageList({
|
|||||||
message={msg}
|
message={msg}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -71,12 +76,18 @@ export function MessageList({
|
|||||||
const message = group.messages[0];
|
const message = group.messages[0];
|
||||||
if (message && hasContent(message)) {
|
if (message && hasContent(message)) {
|
||||||
return (
|
return (
|
||||||
<MarkdownContent
|
<div key={group.id} className="w-full">
|
||||||
key={group.id}
|
<MarkdownContent
|
||||||
content={extractContentFromMessage(message)}
|
content={extractContentFromMessage(message)}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
/>
|
/>
|
||||||
|
<MessageTokenUsageList
|
||||||
|
enabled={tokenUsageEnabled}
|
||||||
|
isLoading={thread.isLoading}
|
||||||
|
messages={group.messages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -99,6 +110,11 @@ export function MessageList({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ArtifactFileList files={files} threadId={threadId} />
|
<ArtifactFileList files={files} threadId={threadId} />
|
||||||
|
<MessageTokenUsageList
|
||||||
|
enabled={tokenUsageEnabled}
|
||||||
|
isLoading={thread.isLoading}
|
||||||
|
messages={group.messages}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (group.type === "assistant:subagent") {
|
} else if (group.type === "assistant:subagent") {
|
||||||
@ -191,15 +207,31 @@ export function MessageList({
|
|||||||
className="relative z-1 flex flex-col gap-2"
|
className="relative z-1 flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
{results}
|
{results}
|
||||||
|
<MessageTokenUsageList
|
||||||
|
enabled={tokenUsageEnabled}
|
||||||
|
isLoading={thread.isLoading}
|
||||||
|
messages={group.messages}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const tokenUsageMessages = group.messages.filter(
|
||||||
|
(message) =>
|
||||||
|
message.type === "ai" &&
|
||||||
|
(hasToolCalls(message) ? true : !hasContent(message)),
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<MessageGroup
|
<div key={"group-" + group.id} className="w-full">
|
||||||
key={"group-" + group.id}
|
<MessageGroup
|
||||||
messages={group.messages}
|
messages={group.messages}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
/>
|
/>
|
||||||
|
<MessageTokenUsageList
|
||||||
|
enabled={tokenUsageEnabled}
|
||||||
|
isLoading={thread.isLoading}
|
||||||
|
messages={tokenUsageMessages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { CoinsIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
|
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function MessageTokenUsage({
|
||||||
|
className,
|
||||||
|
enabled = false,
|
||||||
|
isLoading = false,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
message: Message;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
if (!enabled || isLoading || message.type !== "ai") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = getUsageMetadata(message);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground border-border/60 mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 border-t pt-2 text-[11px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1 font-medium">
|
||||||
|
<CoinsIcon className="size-3" />
|
||||||
|
{t.tokenUsage.label}
|
||||||
|
</span>
|
||||||
|
{usage ? (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t.tokenUsage.output}: {formatTokenCount(usage.outputTokens)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{t.tokenUsage.unavailableShort}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageTokenUsageList({
|
||||||
|
className,
|
||||||
|
enabled = false,
|
||||||
|
isLoading = false,
|
||||||
|
messages,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
messages: Message[];
|
||||||
|
}) {
|
||||||
|
if (!enabled || isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiMessages = messages.filter((message) => message.type === "ai");
|
||||||
|
|
||||||
|
if (aiMessages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{aiMessages.map((message, index) => (
|
||||||
|
<MessageTokenUsage
|
||||||
|
className={className}
|
||||||
|
enabled={enabled}
|
||||||
|
isLoading={isLoading}
|
||||||
|
key={message.id ?? index}
|
||||||
|
message={message}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,18 +15,20 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
interface TokenUsageIndicatorProps {
|
interface TokenUsageIndicatorProps {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
enabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TokenUsageIndicator({
|
export function TokenUsageIndicator({
|
||||||
messages,
|
messages,
|
||||||
|
enabled = false,
|
||||||
className,
|
className,
|
||||||
}: TokenUsageIndicatorProps) {
|
}: TokenUsageIndicatorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const usage = useMemo(() => accumulateUsage(messages), [messages]);
|
const usage = useMemo(() => accumulateUsage(messages), [messages]);
|
||||||
|
|
||||||
if (!usage) {
|
if (!enabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,37 +38,49 @@ export function TokenUsageIndicator({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground flex cursor-default items-center gap-1 text-xs",
|
"text-muted-foreground bg-background/70 flex cursor-default items-center gap-1.5 rounded-full border px-2 py-1 text-xs",
|
||||||
|
!usage && "opacity-60",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CoinsIcon size={14} />
|
<CoinsIcon size={14} />
|
||||||
<span>{formatTokenCount(usage.totalTokens)}</span>
|
<span>{t.tokenUsage.label}</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{usage ? formatTokenCount(usage.totalTokens) : "-"}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="end">
|
<TooltipContent side="bottom" align="end">
|
||||||
<div className="space-y-1 text-xs">
|
<div className="space-y-1 text-xs">
|
||||||
<div className="font-medium">{t.tokenUsage.title}</div>
|
<div className="font-medium">{t.tokenUsage.title}</div>
|
||||||
<div className="flex justify-between gap-4">
|
{usage ? (
|
||||||
<span>{t.tokenUsage.input}</span>
|
<>
|
||||||
<span className="font-mono">
|
<div className="flex justify-between gap-4">
|
||||||
{formatTokenCount(usage.inputTokens)}
|
<span>{t.tokenUsage.input}</span>
|
||||||
</span>
|
<span className="font-mono">
|
||||||
</div>
|
{formatTokenCount(usage.inputTokens)}
|
||||||
<div className="flex justify-between gap-4">
|
</span>
|
||||||
<span>{t.tokenUsage.output}</span>
|
</div>
|
||||||
<span className="font-mono">
|
<div className="flex justify-between gap-4">
|
||||||
{formatTokenCount(usage.outputTokens)}
|
<span>{t.tokenUsage.output}</span>
|
||||||
</span>
|
<span className="font-mono">
|
||||||
</div>
|
{formatTokenCount(usage.outputTokens)}
|
||||||
<div className="border-t pt-1">
|
</span>
|
||||||
<div className="flex justify-between gap-4">
|
</div>
|
||||||
<span>{t.tokenUsage.total}</span>
|
<div className="border-t pt-1">
|
||||||
<span className="font-mono font-medium">
|
<div className="flex justify-between gap-4">
|
||||||
{formatTokenCount(usage.totalTokens)}
|
<span>{t.tokenUsage.total}</span>
|
||||||
</span>
|
<span className="font-mono font-medium">
|
||||||
|
{formatTokenCount(usage.totalTokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground max-w-56">
|
||||||
|
{t.tokenUsage.unavailable}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@ -298,9 +298,13 @@ export const enUS: Translations = {
|
|||||||
// Token Usage
|
// Token Usage
|
||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
title: "Token Usage",
|
title: "Token Usage",
|
||||||
|
label: "Tokens",
|
||||||
input: "Input",
|
input: "Input",
|
||||||
output: "Output",
|
output: "Output",
|
||||||
total: "Total",
|
total: "Total",
|
||||||
|
unavailable:
|
||||||
|
"No token usage yet. Usage appears only after a successful model response when the provider returns usage_metadata.",
|
||||||
|
unavailableShort: "No usage returned",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Shortcuts
|
// Shortcuts
|
||||||
|
|||||||
@ -229,9 +229,12 @@ export interface Translations {
|
|||||||
// Token Usage
|
// Token Usage
|
||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
title: string;
|
title: string;
|
||||||
|
label: string;
|
||||||
input: string;
|
input: string;
|
||||||
output: string;
|
output: string;
|
||||||
total: string;
|
total: string;
|
||||||
|
unavailable: string;
|
||||||
|
unavailableShort: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shortcuts
|
// Shortcuts
|
||||||
|
|||||||
@ -284,9 +284,13 @@ export const zhCN: Translations = {
|
|||||||
// Token Usage
|
// Token Usage
|
||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
title: "Token 用量",
|
title: "Token 用量",
|
||||||
|
label: "Tokens",
|
||||||
input: "输入",
|
input: "输入",
|
||||||
output: "输出",
|
output: "输出",
|
||||||
total: "总计",
|
total: "总计",
|
||||||
|
unavailable:
|
||||||
|
"暂无 Token 用量。只有模型成功返回且供应商提供 usage_metadata 时才会显示。",
|
||||||
|
unavailableShort: "未返回用量",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Shortcuts
|
// Shortcuts
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export interface TokenUsage {
|
|||||||
* Extract usage_metadata from an AI message if present.
|
* Extract usage_metadata from an AI message if present.
|
||||||
* The field is added by the backend (PR #1218) but not typed in the SDK.
|
* The field is added by the backend (PR #1218) but not typed in the SDK.
|
||||||
*/
|
*/
|
||||||
function getUsageMetadata(message: Message): TokenUsage | null {
|
export function getUsageMetadata(message: Message): TokenUsage | null {
|
||||||
if (message.type !== "ai") {
|
if (message.type !== "ai") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL } from "../config";
|
||||||
|
|
||||||
import type { Model } from "./types";
|
import type { ModelsResponse } from "./types";
|
||||||
|
|
||||||
export async function loadModels() {
|
export async function loadModels(): Promise<ModelsResponse> {
|
||||||
const res = await fetch(`${getBackendBaseURL()}/api/models`);
|
const res = await fetch(`${getBackendBaseURL()}/api/models`);
|
||||||
const { models } = (await res.json()) as { models: Model[] };
|
const data = (await res.json()) as Partial<ModelsResponse>;
|
||||||
return models;
|
return {
|
||||||
|
models: data.models ?? [],
|
||||||
|
token_usage: data.token_usage ?? { enabled: false },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,5 +9,10 @@ export function useModels({ enabled = true }: { enabled?: boolean } = {}) {
|
|||||||
enabled,
|
enabled,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
return { models: data ?? [], isLoading, error };
|
return {
|
||||||
|
models: data?.models ?? [],
|
||||||
|
tokenUsageEnabled: data?.token_usage.enabled ?? false,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,3 +7,12 @@ export interface Model {
|
|||||||
supports_thinking?: boolean;
|
supports_thinking?: boolean;
|
||||||
supports_reasoning_effort?: boolean;
|
supports_reasoning_effort?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TokenUsageSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsResponse {
|
||||||
|
models: Model[];
|
||||||
|
token_usage: TokenUsageSettings;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user