Skip to content

Commit 12abbcb

Browse files
authored
Merge pull request #31 from aguung/feat/token-usage-counter
feat: add token usage counter per session
2 parents 0c76ade + 3f108e2 commit 12abbcb

5 files changed

Lines changed: 164 additions & 19 deletions

File tree

src-tauri/src/services/chat_service.rs

Lines changed: 108 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,42 @@ fn needs_visual_guide(content: &str) -> bool {
3333
VISUAL_KEYWORDS.iter().any(|kw| lower.contains(kw))
3434
}
3535

36+
#[derive(Debug, Clone, serde::Serialize)]
37+
#[serde(rename_all = "camelCase")]
38+
pub struct TokenUsage {
39+
pub prompt_tokens: u32,
40+
pub completion_tokens: u32,
41+
pub total_tokens: u32,
42+
}
43+
44+
#[derive(Debug, Clone, serde::Serialize)]
45+
#[serde(rename_all = "camelCase")]
46+
struct ChatUsageEvent {
47+
session_id: String,
48+
usage: TokenUsage,
49+
}
50+
51+
#[derive(Debug, Default)]
52+
struct UsageAccumulator {
53+
prompt_tokens: u32,
54+
completion_tokens: u32,
55+
}
56+
57+
impl UsageAccumulator {
58+
fn finish(self) -> Option<TokenUsage> {
59+
let total = self.prompt_tokens + self.completion_tokens;
60+
if total == 0 {
61+
None
62+
} else {
63+
Some(TokenUsage {
64+
prompt_tokens: self.prompt_tokens,
65+
completion_tokens: self.completion_tokens,
66+
total_tokens: total,
67+
})
68+
}
69+
}
70+
}
71+
3672
pub async fn get_messages(db: &SqlitePool, session_id: &str) -> AppResult<Vec<Message>> {
3773
let messages = sqlx::query_as::<_, Message>(
3874
"SELECT id, session_id, role, content, created_at FROM messages \
@@ -280,7 +316,7 @@ async fn send_message_inner(
280316
if provider.uses_anthropic_format() { "anthropic" } else { "openai" },
281317
);
282318

283-
let assistant_output = if provider.uses_anthropic_format() {
319+
let (assistant_output, token_usage) = if provider.uses_anthropic_format() {
284320
send_anthropic(
285321
history,
286322
model,
@@ -292,17 +328,29 @@ async fn send_message_inner(
292328
)
293329
.await?
294330
} else {
331+
let supports_stream_usage = provider.provider_type == "openai";
295332
send_openai_compatible(
296333
&provider.base_url,
297334
model,
298335
provider.api_key.as_deref(),
336+
supports_stream_usage,
299337
history,
300338
&on_token,
301339
&cancel_token,
302340
)
303341
.await?
304342
};
305343

344+
if let Some(usage) = token_usage {
345+
let _ = app_handle.emit(
346+
"chat-usage",
347+
ChatUsageEvent {
348+
session_id: session_id.to_string(),
349+
usage,
350+
},
351+
);
352+
}
353+
306354
let assistant_message = Message {
307355
id: Uuid::new_v4().to_string(),
308356
session_id: session_id.to_string(),
@@ -330,10 +378,11 @@ async fn send_openai_compatible(
330378
base_url: &str,
331379
model: &str,
332380
api_key: Option<&str>,
381+
include_usage: bool,
333382
history: Vec<Message>,
334383
on_token: &Channel<String>,
335384
cancel_token: &CancellationToken,
336-
) -> AppResult<String> {
385+
) -> AppResult<(String, Option<TokenUsage>)> {
337386
let client = http_client::streaming_client()?;
338387
let endpoint = format!("{}/chat/completions", base_url.trim_end_matches('/'));
339388

@@ -342,7 +391,7 @@ async fn send_openai_compatible(
342391
.map(|m| serde_json::json!({ "role": m.role, "content": m.content }))
343392
.collect();
344393

345-
let payload = serde_json::json!({
394+
let mut payload = serde_json::json!({
346395
"model": model,
347396
"messages": messages,
348397
"temperature": 0.2,
@@ -351,6 +400,10 @@ async fn send_openai_compatible(
351400
"stream": true,
352401
});
353402

403+
if include_usage {
404+
payload["stream_options"] = serde_json::json!({ "include_usage": true });
405+
}
406+
354407
// Lazy system prompt: only inject full preview guide when user asks for visuals
355408
let last_user_content = history.iter().rev().find(|m| m.role == "user").map(|m| m.content.as_str()).unwrap_or("");
356409
let system_instructions = if needs_visual_guide(last_user_content) {
@@ -412,7 +465,7 @@ async fn send_anthropic(
412465
base_url: &str,
413466
on_token: &Channel<String>,
414467
cancel_token: &CancellationToken,
415-
) -> AppResult<String> {
468+
) -> AppResult<(String, Option<TokenUsage>)> {
416469
let client = http_client::streaming_client()?;
417470

418471
let (system_msgs, chat_msgs): (Vec<_>, Vec<_>) =
@@ -531,7 +584,7 @@ async fn send_anthropic(
531584
return Err(AppError::Http(format!("Anthropic {status}: {body}")));
532585
}
533586

534-
let output = stream_anthropic_sse(response, on_token, cancel_token).await?;
587+
let (output, usage) = stream_anthropic_sse(response, on_token, cancel_token).await?;
535588

536589
// Fallback: some gateways return message_start → message_stop without any
537590
// content_block events for certain models. Retry non-streaming.
@@ -569,23 +622,24 @@ async fn send_anthropic(
569622
.and_then(Value::as_str)
570623
{
571624
let _ = on_token.send(text.to_string());
572-
return Ok(text.to_string());
625+
return Ok((text.to_string(), usage));
573626
}
574627

575-
return Ok(String::new());
628+
return Ok((String::new(), usage));
576629
}
577630

578-
Ok(output)
631+
Ok((output, usage))
579632
}
580633

581634
async fn stream_openai_sse(
582635
response: reqwest::Response,
583636
on_token: &Channel<String>,
584637
cancel_token: &CancellationToken,
585-
) -> AppResult<String> {
638+
) -> AppResult<(String, Option<TokenUsage>)> {
586639
let mut stream = response.bytes_stream();
587640
let mut line_buffer = String::new();
588641
let mut output = String::new();
642+
let mut usage = UsageAccumulator::default();
589643

590644
loop {
591645
tokio::select! {
@@ -604,8 +658,8 @@ async fn stream_openai_sse(
604658
line.pop();
605659
}
606660

607-
if parse_openai_sse_line(&line, on_token, &mut output)? {
608-
return Ok(output);
661+
if parse_openai_sse_line(&line, on_token, &mut output, &mut usage)? {
662+
return Ok((output, usage.finish()));
609663
}
610664
}
611665
}
@@ -617,16 +671,17 @@ async fn stream_openai_sse(
617671
}
618672

619673
if !line_buffer.is_empty() {
620-
parse_openai_sse_line(&line_buffer, on_token, &mut output)?;
674+
parse_openai_sse_line(&line_buffer, on_token, &mut output, &mut usage)?;
621675
}
622676

623-
Ok(output)
677+
Ok((output, usage.finish()))
624678
}
625679

626680
fn parse_openai_sse_line(
627681
line: &str,
628682
on_token: &Channel<String>,
629683
output: &mut String,
684+
usage: &mut UsageAccumulator,
630685
) -> AppResult<bool> {
631686
let trimmed = line.trim();
632687
if trimmed.is_empty() {
@@ -642,6 +697,16 @@ fn parse_openai_sse_line(
642697
}
643698

644699
let value: Value = serde_json::from_str(payload)?;
700+
701+
if let Some(u) = value.get("usage") {
702+
if let Some(pt) = u.get("prompt_tokens").and_then(Value::as_u64) {
703+
usage.prompt_tokens = pt as u32;
704+
}
705+
if let Some(ct) = u.get("completion_tokens").and_then(Value::as_u64) {
706+
usage.completion_tokens = ct as u32;
707+
}
708+
}
709+
645710
if let Some(token) = value
646711
.get("choices")
647712
.and_then(Value::as_array)
@@ -661,14 +726,15 @@ async fn stream_anthropic_sse(
661726
response: reqwest::Response,
662727
on_token: &Channel<String>,
663728
cancel_token: &CancellationToken,
664-
) -> AppResult<String> {
729+
) -> AppResult<(String, Option<TokenUsage>)> {
665730
let mut stream = response.bytes_stream();
666731
let mut line_buffer = String::new();
667732
let mut output = String::new();
668733
// Track the most recent `event:` line so we can use it when parsing the
669734
// subsequent `data:` line. Some gateways omit the `"type"` field from
670735
// the JSON payload, so we fall back to the SSE event name.
671736
let mut current_event = String::new();
737+
let mut usage = UsageAccumulator::default();
672738
let mut message_stop_received = false;
673739

674740
'outer: loop {
@@ -688,7 +754,13 @@ async fn stream_anthropic_sse(
688754
line.pop();
689755
}
690756

691-
if parse_anthropic_sse_line(&line, &mut current_event, on_token, &mut output)? {
757+
if parse_anthropic_sse_line(
758+
&line,
759+
&mut current_event,
760+
on_token,
761+
&mut output,
762+
&mut usage,
763+
)? {
692764
message_stop_received = true;
693765
break 'outer;
694766
}
@@ -707,7 +779,7 @@ async fn stream_anthropic_sse(
707779
));
708780
}
709781

710-
Ok(output)
782+
Ok((output, usage.finish()))
711783
}
712784

713785
/// Parse a single SSE line from an Anthropic-format stream.
@@ -720,6 +792,7 @@ fn parse_anthropic_sse_line(
720792
current_event: &mut String,
721793
on_token: &Channel<String>,
722794
output: &mut String,
795+
usage: &mut UsageAccumulator,
723796
) -> AppResult<bool> {
724797
let trimmed = line.trim();
725798
if trimmed.is_empty() {
@@ -752,6 +825,25 @@ fn parse_anthropic_sse_line(
752825
.unwrap_or(current_event.as_str());
753826

754827
match event_type {
828+
"message_start" => {
829+
if let Some(pt) = value
830+
.get("message")
831+
.and_then(|m| m.get("usage"))
832+
.and_then(|u| u.get("input_tokens"))
833+
.and_then(Value::as_u64)
834+
{
835+
usage.prompt_tokens = pt as u32;
836+
}
837+
}
838+
"message_delta" => {
839+
if let Some(ct) = value
840+
.get("usage")
841+
.and_then(|u| u.get("output_tokens"))
842+
.and_then(Value::as_u64)
843+
{
844+
usage.completion_tokens = ct as u32;
845+
}
846+
}
755847
"content_block_delta" => {
756848
if let Some(token) = value
757849
.get("delta")

src/components/layout/AppShell.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ import { useUIStore } from '@/stores/useUIStore';
1616
import { useAgentStore } from '@/stores/useAgentStore';
1717
import { SettingsModal } from '@/components/settings/SettingsModal';
1818
import { ExcalidrawCanvas } from '@/components/canvas/ExcalidrawCanvas';
19-
import { AgentConfig, AgentRunWithTools, AgentType, Message, PermissionRequest, Project, Provider, ProviderModelConfig, Session, ToolCall } from '@/types';
19+
import { AgentConfig, AgentRunWithTools, AgentType, ChatUsageEvent, Message, PermissionRequest, Project, Provider, ProviderModelConfig, Session, ToolCall } from '@/types';
2020
import { cn } from '@/lib/utils';
2121

2222
export const AppShell: React.FC = () => {
23-
const { addMessage, appendStreamToken, setStreaming, clearStreaming, setMessages } = useChatStore();
23+
const { addMessage, appendStreamToken, setStreaming, clearStreaming, setMessages, addTokenUsage } = useChatStore();
2424
const setProjects = useProjectStore((s) => s.setProjects);
2525
const addProject = useProjectStore((s) => s.addProject);
2626
const setActiveProjectId = useProjectStore((s) => s.setActiveProjectId);
@@ -182,6 +182,10 @@ export const AppShell: React.FC = () => {
182182
clearStreaming();
183183
});
184184

185+
const unlistenChatUsage = await listen<ChatUsageEvent>('chat-usage', (event) => {
186+
addTokenUsage(event.payload.sessionId, event.payload.usage);
187+
});
188+
185189
const unlistenAgentStarted = await listen<{
186190
agentRunId: string;
187191
agentType: string;
@@ -326,6 +330,7 @@ export const AppShell: React.FC = () => {
326330
localUnlisten.push(
327331
unlistenChatDone,
328332
unlistenChatError,
333+
unlistenChatUsage,
329334
unlistenAgentStarted,
330335
unlistenAgentToken,
331336
unlistenAgentToolCall,
@@ -348,6 +353,7 @@ export const AppShell: React.FC = () => {
348353
};
349354
}, [
350355
clearStreaming,
356+
addTokenUsage,
351357
addAgentRun,
352358
appendAgentToken,
353359
flushThinkingBlock,

src/components/layout/ChatHeader.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,14 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({ onToggleLeftSidebar }) =
2525
const mainView = useUIStore((s) => s.mainView);
2626
const setMainView = useUIStore((s) => s.setMainView);
2727
const { activeProjectId } = useProjectStore();
28+
const sessionUsage = useChatStore((s) => s.sessionUsage);
2829

2930
const { theme, toggleTheme } = useUIStore();
3031
const activeSession = sessions.find(s => s.id === activeSessionId);
32+
const currentUsage = activeSessionId ? sessionUsage[activeSessionId] : undefined;
33+
34+
const formatTokens = (n: number) =>
35+
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
3136

3237
const [isRenaming, setIsRenaming] = useState(false);
3338
const [renameValue, setRenameValue] = useState('');
@@ -165,6 +170,14 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({ onToggleLeftSidebar }) =
165170
<PencilSimple size={14} />
166171
</button>
167172
)}
173+
{currentUsage && (
174+
<span
175+
className="text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-[var(--surface-2)] text-[var(--text-muted)] border border-[var(--border)] whitespace-nowrap"
176+
title={`↑ ${formatTokens(currentUsage.promptTokens)} prompt · ↓ ${formatTokens(currentUsage.completionTokens)} completion`}
177+
>
178+
{formatTokens(currentUsage.totalTokens)} tokens
179+
</span>
180+
)}
168181
</>
169182
)}
170183
</div>

0 commit comments

Comments
 (0)