@@ -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+
3672pub 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 = reqwest:: Client :: new ( ) ;
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 = reqwest:: Client :: new ( ) ;
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
581634async 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
626680fn 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,16 +726,18 @@ 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 ( ) ;
738+ let mut message_stop_received = false ;
672739
673- loop {
740+ ' outer : loop {
674741 tokio:: select! {
675742 _ = cancel_token. cancelled( ) => {
676743 return Err ( AppError :: Cancelled ) ;
@@ -687,8 +754,15 @@ async fn stream_anthropic_sse(
687754 line. pop( ) ;
688755 }
689756
690- if parse_anthropic_sse_line( & line, & mut current_event, on_token, & mut output) ? {
691- return Ok ( output) ;
757+ if parse_anthropic_sse_line(
758+ & line,
759+ & mut current_event,
760+ on_token,
761+ & mut output,
762+ & mut usage,
763+ ) ? {
764+ message_stop_received = true ;
765+ break ' outer;
692766 }
693767 }
694768 }
@@ -699,7 +773,13 @@ async fn stream_anthropic_sse(
699773 }
700774 }
701775
702- Ok ( output)
776+ if !message_stop_received {
777+ return Err ( AppError :: Http (
778+ "Stream ended without completion signal — connection may have been interrupted. Please retry." . to_string ( ) ,
779+ ) ) ;
780+ }
781+
782+ Ok ( ( output, usage. finish ( ) ) )
703783}
704784
705785/// Parse a single SSE line from an Anthropic-format stream.
@@ -712,6 +792,7 @@ fn parse_anthropic_sse_line(
712792 current_event : & mut String ,
713793 on_token : & Channel < String > ,
714794 output : & mut String ,
795+ usage : & mut UsageAccumulator ,
715796) -> AppResult < bool > {
716797 let trimmed = line. trim ( ) ;
717798 if trimmed. is_empty ( ) {
@@ -734,10 +815,7 @@ fn parse_anthropic_sse_line(
734815 } ;
735816 let payload = payload. trim ( ) ;
736817
737- let value: Value = match serde_json:: from_str ( payload) {
738- Ok ( v) => v,
739- Err ( _) => return Ok ( false ) ,
740- } ;
818+ let value: Value = serde_json:: from_str ( payload) ?;
741819
742820 // Prefer `"type"` from the JSON payload; fall back to the preceding
743821 // `event:` line when the gateway strips it.
@@ -747,6 +825,25 @@ fn parse_anthropic_sse_line(
747825 . unwrap_or ( current_event. as_str ( ) ) ;
748826
749827 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+ }
750847 "content_block_delta" => {
751848 if let Some ( token) = value
752849 . get ( "delta" )
0 commit comments