diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index c04641df0..783ca8765 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1326,6 +1326,7 @@ impl CliSession { Some(Ok(AgentEvent::Usage(usage))) => { last_usage = Some(usage); } + Some(Ok(AgentEvent::MessageUsage { .. })) => {} Some(Ok(AgentEvent::McpNotification((extension_id, notification)))) => { handle_mcp_notification( &extension_id, diff --git a/crates/goose-sdk-types/src/custom_notifications.rs b/crates/goose-sdk-types/src/custom_notifications.rs index 3170e93cc..868e3185d 100644 --- a/crates/goose-sdk-types/src/custom_notifications.rs +++ b/crates/goose-sdk-types/src/custom_notifications.rs @@ -25,12 +25,14 @@ pub struct GooseSessionNotification { "propertyName": "sessionUpdate", "mapping": { "usage_update": "#/$defs/SessionUsageUpdate", - "status_message": "#/$defs/StatusMessageUpdate" + "status_message": "#/$defs/StatusMessageUpdate", + "message_usage": "#/$defs/MessageUsageUpdate" } }))] pub enum GooseSessionUpdate { UsageUpdate(SessionUsageUpdate), StatusMessage(StatusMessageUpdate), + MessageUsage(MessageUsageUpdate), } impl Default for GooseSessionUpdate { @@ -51,6 +53,55 @@ pub struct SessionUsageUpdate { pub accumulated_cost: Option, } +/// Per-message token usage/cost/timing, keyed by the message id used for +/// chunk matching. Sent live after a turn's messages and on replay. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MessageUsageUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, + pub usage: MessageUsageData, +} + +/// Wire mirror of the conversation `MessageUsage` (this crate cannot depend on +/// goose-provider-types); field names and serde casing MUST stay in parity. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MessageUsageData { + #[serde(skip_serializing_if = "Option::is_none")] + pub input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_read_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_write_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_source: Option, + /// Wall-clock generation time, used by the client for a tokens/sec readout. + #[serde(skip_serializing_if = "Option::is_none")] + pub elapsed_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub time_to_first_token_ms: Option, + /// Usage from a compaction/summarization call rather than a normal turn. + #[serde(default)] + pub is_compaction: bool, +} + +/// Wire mirror of the conversation `CostSource`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CostSourceData { + /// Cost returned directly by the provider (e.g. OpenRouter `usage.cost`). + ProviderReported, + /// Cost computed from the canonical pricing table. + Estimated, +} + /// Live UI/session status. This is not conversation transcript content, and /// should not be persisted or replayed as history. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -126,4 +177,50 @@ mod tests { }) ); } + + #[test] + fn message_usage_serializes_to_expected_wire_shape() { + let notification = GooseSessionNotification { + session_id: "s1".to_string(), + update: GooseSessionUpdate::MessageUsage(MessageUsageUpdate { + message_id: Some("m1".to_string()), + usage: MessageUsageData { + input_tokens: Some(1200), + output_tokens: Some(340), + total_tokens: Some(1540), + cache_read_tokens: Some(1000), + cache_write_tokens: None, + cost: Some(0.0123), + cost_source: Some(CostSourceData::Estimated), + elapsed_ms: Some(4200), + time_to_first_token_ms: Some(840), + is_compaction: false, + }, + }), + }; + + let value = serde_json::to_value(notification).unwrap(); + + assert_eq!( + value, + json!({ + "sessionId": "s1", + "update": { + "sessionUpdate": "message_usage", + "messageId": "m1", + "usage": { + "inputTokens": 1200, + "outputTokens": 340, + "totalTokens": 1540, + "cacheReadTokens": 1000, + "cost": 0.0123, + "costSource": "estimated", + "elapsedMs": 4200, + "timeToFirstTokenMs": 840, + "isCompaction": false + } + } + }) + ); + } } diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index e06162849..4deb57563 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -12,7 +12,7 @@ use axum::{ use bytes::Bytes; use futures::{stream::StreamExt, Stream}; use goose::agents::{AgentEvent, SessionConfig}; -use goose::conversation::message::{Message, MessageContent, TokenState}; +use goose::conversation::message::{Message, MessageContent, MessageUsage, TokenState}; use goose::conversation::Conversation; use goose::session::SessionManager; use rmcp::model::ServerNotification; @@ -130,6 +130,12 @@ pub enum MessageEvent { message: Message, token_state: TokenState, }, + /// Per-message token usage/cost, sent after the corresponding `Message` + /// event once the provider call's usage is known. + MessageUsage { + message_id: Option, + usage: MessageUsage, + }, Error { error: String, }, @@ -354,6 +360,9 @@ pub async fn reply( stream_event(MessageEvent::Message { message, token_state }, &tx, &cancel_token).await; } Ok(Some(Ok(AgentEvent::Usage(_)))) => {} + Ok(Some(Ok(AgentEvent::MessageUsage { message_id, usage }))) => { + stream_event(MessageEvent::MessageUsage { message_id, usage }, &tx, &cancel_token).await; + } Ok(Some(Ok(AgentEvent::HistoryReplaced(new_messages)))) => { all_messages = new_messages.clone(); stream_event(MessageEvent::UpdateConversation {conversation: new_messages}, &tx, &cancel_token).await; diff --git a/crates/goose-server/src/routes/session_events.rs b/crates/goose-server/src/routes/session_events.rs index 8dc76d2a5..339515247 100644 --- a/crates/goose-server/src/routes/session_events.rs +++ b/crates/goose-server/src/routes/session_events.rs @@ -481,6 +481,13 @@ pub async fn session_reply( .await; } Ok(Some(Ok(AgentEvent::Usage(_)))) => {} + Ok(Some(Ok(AgentEvent::MessageUsage { message_id, usage }))) => { + publish( + Some(task_request_id.clone()), + MessageEvent::MessageUsage { message_id, usage }, + ) + .await; + } Ok(Some(Ok(AgentEvent::HistoryReplaced(new_messages)))) => { all_messages = new_messages.clone(); publish( diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index f4507f5cf..87e8509a0 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -6564,6 +6564,19 @@ "required": [ "sessionUpdate" ] + }, + { + "$ref": "#/$defs/MessageUsageUpdate", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "message_usage" + } + }, + "required": [ + "sessionUpdate" + ] } ], "description": "Discriminated union of goose-specific session update payloads.\nVariant tag matches ACP's convention (`sessionUpdate: \"\"`).\n\n`discriminator.mapping` is what makes TS codegen (`@hey-api/openapi-ts`)\nemit the correct snake_case tag value even when this enum has a single\nvariant. Add a mapping entry per variant.", @@ -6571,7 +6584,8 @@ "propertyName": "sessionUpdate", "mapping": { "usage_update": "#/$defs/SessionUsageUpdate", - "status_message": "#/$defs/StatusMessageUpdate" + "status_message": "#/$defs/StatusMessageUpdate", + "message_usage": "#/$defs/MessageUsageUpdate" } } }, @@ -6658,6 +6672,112 @@ ], "description": "Live UI/session status. This is not conversation transcript content, and\nshould not be persisted or replayed as history." }, + "MessageUsageData": { + "type": "object", + "properties": { + "inputTokens": { + "type": [ + "integer", + "null" + ] + }, + "outputTokens": { + "type": [ + "integer", + "null" + ] + }, + "totalTokens": { + "type": [ + "integer", + "null" + ] + }, + "cacheReadTokens": { + "type": [ + "integer", + "null" + ] + }, + "cacheWriteTokens": { + "type": [ + "integer", + "null" + ] + }, + "cost": { + "type": [ + "number", + "null" + ], + "format": "double" + }, + "costSource": { + "anyOf": [ + { + "$ref": "#/$defs/CostSourceData" + }, + { + "type": "null" + } + ] + }, + "elapsedMs": { + "type": [ + "integer", + "null" + ], + "description": "Wall-clock generation time, used by the client for a tokens/sec readout.", + "minimum": 0 + }, + "timeToFirstTokenMs": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "isCompaction": { + "type": "boolean", + "description": "Usage from a compaction/summarization call rather than a normal turn.", + "default": false + } + }, + "description": "Wire mirror of the conversation `MessageUsage` (this crate cannot depend on\ngoose-provider-types); field names and serde casing MUST stay in parity." + }, + "CostSourceData": { + "oneOf": [ + { + "type": "string", + "const": "provider_reported", + "description": "Cost returned directly by the provider (e.g. OpenRouter `usage.cost`)." + }, + { + "type": "string", + "const": "estimated", + "description": "Cost computed from the canonical pricing table." + } + ], + "description": "Wire mirror of the conversation `CostSource`." + }, + "MessageUsageUpdate": { + "type": "object", + "properties": { + "messageId": { + "type": [ + "string", + "null" + ] + }, + "usage": { + "$ref": "#/$defs/MessageUsageData" + } + }, + "required": [ + "usage" + ], + "description": "Per-message token usage/cost/timing, keyed by the message id used for\nchunk matching. Sent live after a turn's messages and on replay." + }, "RequestRecipeParams_unstable": { "type": "object", "properties": { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 103126c29..e6e5ac626 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -2086,6 +2086,33 @@ fn status_message_from_system_notification( } } +/// Conversion to the sdk-types wire mirror carried by `message_usage`. +fn message_usage_update( + message_id: Option, + usage: &crate::conversation::message::MessageUsage, +) -> MessageUsageUpdate { + use crate::conversation::token_usage::CostSource; + + MessageUsageUpdate { + message_id, + usage: MessageUsageData { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + cache_read_tokens: usage.cache_read_tokens, + cache_write_tokens: usage.cache_write_tokens, + cost: usage.cost, + cost_source: usage.cost_source.map(|source| match source { + CostSource::ProviderReported => CostSourceData::ProviderReported, + CostSource::Estimated => CostSourceData::Estimated, + }), + elapsed_ms: usage.elapsed_ms, + time_to_first_token_ms: usage.time_to_first_token_ms, + is_compaction: usage.is_compaction, + }, + } +} + fn message_update_meta(message_id: Option<&str>, created: i64, steer: bool) -> Meta { let mut goose = serde_json::Map::new(); goose.insert("created".to_string(), serde_json::json!(created)); @@ -2660,6 +2687,16 @@ impl GooseAcpAgent { ))?; } } + Ok(crate::agents::AgentEvent::MessageUsage { message_id, usage }) => { + if self.supports_goose_custom_notifications() { + cx.send_notification(GooseSessionNotification { + session_id: session_id.clone(), + update: GooseSessionUpdate::MessageUsage(message_usage_update( + message_id, &usage, + )), + })?; + } + } Ok(_) => {} Err(e) => { stream_error = Some( diff --git a/crates/goose/src/acp/server/load_session.rs b/crates/goose/src/acp/server/load_session.rs index dc8ea3db5..d071971a7 100644 --- a/crates/goose/src/acp/server/load_session.rs +++ b/crates/goose/src/acp/server/load_session.rs @@ -29,6 +29,7 @@ fn send_replay_content_chunk( fn replay_conversation_to_client( cx: &ConnectionTo, session: &Session, + supports_goose_custom_notifications: bool, ) -> Result, agent_client_protocol::Error> { let session_id = SessionId::new(session.id.clone()); @@ -158,6 +159,18 @@ fn replay_conversation_to_client( _ => {} } } + + if supports_goose_custom_notifications { + if let Some(usage) = &message.metadata.usage { + cx.send_notification(GooseSessionNotification { + session_id: session.id.clone(), + update: GooseSessionUpdate::MessageUsage(message_usage_update( + message.id.clone(), + usage, + )), + })?; + } + } } Ok(replay_tool_requests) @@ -189,7 +202,11 @@ impl GooseAcpAgent { .prepare_session_for_activation(session, args.cwd.clone(), args.mcp_servers, true) .await?; - let replay_tool_requests = replay_conversation_to_client(cx, &session)?; + let replay_tool_requests = replay_conversation_to_client( + cx, + &session, + self.supports_goose_custom_notifications(), + )?; let (agent, extension_results) = self.prepare_acp_session_agent(cx, &session).await?; self.apply_session_recipe(&agent, &session).await?; self.register_acp_session(session_id_str.clone(), agent.clone(), replay_tool_requests) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index e14aa3516..95220cf74 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -265,19 +265,26 @@ pub struct Agent { pub enum AgentEvent { Message(Message), Usage(crate::providers::base::ProviderUsage), + MessageUsage { + message_id: Option, + usage: MessageUsage, + }, McpNotification((String, ServerNotification)), HistoryReplaced(Conversation), } -fn attach_turn_usage(messages: &mut Conversation, usage: &ProviderUsage) { - if let Some(message) = messages +fn attach_turn_usage( + messages: &mut Conversation, + usage: &ProviderUsage, +) -> Option<(Option, MessageUsage)> { + let message = messages .messages_mut() .iter_mut() .rev() - .find(|m| m.role == rmcp::model::Role::Assistant) - { - message.metadata.usage = Some(Box::new(MessageUsage::from_provider_usage(usage, false))); - } + .find(|m| m.role == rmcp::model::Role::Assistant)?; + let message_usage = MessageUsage::from_provider_usage(usage, false); + message.metadata.usage = Some(Box::new(message_usage.clone())); + Some((message.id.clone(), message_usage)) } impl Default for Agent { @@ -2680,7 +2687,11 @@ impl Agent { }; if let Some(usage) = pending_turn_usage.take() { - attach_turn_usage(&mut messages_to_add, &usage); + if let Some((message_id, usage)) = + attach_turn_usage(&mut messages_to_add, &usage) + { + yield AgentEvent::MessageUsage { message_id, usage }; + } } for msg in &messages_to_add { @@ -3769,7 +3780,8 @@ echo start >> "$PLUGIN_ROOT/hook.log" AgentEvent::Message(message) => messages.push(message), AgentEvent::McpNotification(_) | AgentEvent::HistoryReplaced(_) - | AgentEvent::Usage(_) => {} + | AgentEvent::Usage(_) + | AgentEvent::MessageUsage { .. } => {} } } Ok(messages) @@ -4005,4 +4017,50 @@ echo start >> "$PLUGIN_ROOT/hook.log" let input = serde_json::json!({ "path": "" }); assert!(extract_string_arg(&input, &["path"]).is_none()); } + + #[test] + fn attach_turn_usage_targets_last_assistant_message() { + let usage = ProviderUsage::new( + "test-model".to_string(), + Usage::new(Some(1200), Some(340), None), + ); + let mut conversation = Conversation::new_unvalidated([ + Message::user().with_text("hi"), + Message::assistant().with_id("a1").with_text("first"), + Message::user().with_text("again"), + Message::assistant().with_id("a2").with_text("second"), + ]); + + let (message_id, attached) = + attach_turn_usage(&mut conversation, &usage).expect("usage should attach"); + + assert_eq!(message_id.as_deref(), Some("a2")); + assert_eq!(attached.input_tokens, Some(1200)); + assert_eq!(attached.output_tokens, Some(340)); + assert!(!attached.is_compaction, "turn usage is not a compaction"); + + let messages = conversation.messages(); + let stored = messages[3] + .metadata + .usage + .as_deref() + .expect("usage must be stored on the last assistant message"); + assert_eq!(*stored, attached); + assert!( + messages[1].metadata.usage.is_none(), + "earlier assistant message must not receive the usage" + ); + } + + #[test] + fn attach_turn_usage_returns_none_without_assistant_message() { + let usage = ProviderUsage::new("test-model".to_string(), Usage::default()); + let mut conversation = Conversation::new_unvalidated([Message::user().with_text("hi")]); + + assert!(attach_turn_usage(&mut conversation, &usage).is_none()); + assert!( + conversation.messages()[0].metadata.usage.is_none(), + "user message must stay untouched" + ); + } } diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 5eadd8ab4..ee5571b21 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -21,7 +21,7 @@ use crate::providers::toolshim::{ augment_message_with_selected_tool_interpreter, convert_tool_messages_to_text, modify_system_prompt_for_tool_json, sanitize_residual_markers, }; -use goose_providers::conversation::token_usage::{CostSource, ProviderUsage, Usage}; +use goose_providers::conversation::token_usage::{CostSource, ProviderStats, ProviderUsage, Usage}; use goose_providers::model::ModelConfig; use rmcp::model::Tool; use tracing::warn; @@ -143,6 +143,24 @@ async fn toolshim_postprocess( } } +/// Fill `usage.stats` timing fields measured by the stream wrapper, keeping any +/// values the provider already reported (e.g. MLX's own `elapsed_ms`). +fn fill_stream_timing( + usage: &mut ProviderUsage, + request_started: std::time::Instant, + first_content_at: Option, +) { + let stats = usage.stats.get_or_insert_with(ProviderStats::default); + if stats.time_to_first_token_ms.is_none() { + if let Some(first) = first_content_at { + stats.time_to_first_token_ms = Some((first - request_started).as_millis() as u64); + } + } + if stats.elapsed_ms.is_none() { + stats.elapsed_ms = Some(request_started.elapsed().as_millis() as u64); + } +} + impl Agent { pub async fn prepare_tools_and_prompt( &self, @@ -287,6 +305,7 @@ impl Agent { // so they can be handled by the existing error handling logic in the agent let model_config = model_config.with_default_thinking_effort(Config::global().get_goose_thinking_effort()); + let request_started = std::time::Instant::now(); debug!("WAITING_LLM_STREAM_START"); let stream_result = crate::session_context::with_session_id( Some(session_id.to_string()), @@ -320,11 +339,15 @@ impl Agent { // and stripped before any output reaches the UI. let mut accumulated_message: Option = None; let mut final_usage: Option = None; + let mut first_content_at: Option = None; while let Some(result) = stream.next().await { let (msg_opt, usage_opt) = result?; if let Some(msg) = msg_opt { + if first_content_at.is_none() { + first_content_at = Some(std::time::Instant::now()); + } accumulated_message = Some(match accumulated_message { Some(mut prev) => { for new_content in msg.content { @@ -354,6 +377,11 @@ impl Agent { yield (None, None); } + // The toolshim interpreter call below must not count toward elapsed time. + if let Some(usage) = final_usage.as_mut() { + fill_stream_timing(usage, request_started, first_content_at); + } + if let Some(msg) = accumulated_message { let processed = toolshim_postprocess(msg, &toolshim_tools).await?; yield (Some(processed), final_usage); @@ -362,8 +390,16 @@ impl Agent { yield (None, final_usage); } } else { + let mut first_content_at: Option = None; while let Some(result) = stream.next().await { - let (message, usage) = result?; + let (message, mut usage) = result?; + + if message.is_some() && first_content_at.is_none() { + first_content_at = Some(std::time::Instant::now()); + } + if let Some(usage) = usage.as_mut() { + fill_stream_timing(usage, request_started, first_content_at); + } yield (message, usage); } @@ -624,9 +660,10 @@ mod tests { use crate::providers::base::Provider; use crate::session::session_manager::SessionType; use async_trait::async_trait; - use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; + use goose_providers::conversation::token_usage::{ProviderStats, ProviderUsage, Usage}; use goose_providers::model::ModelConfig; use rmcp::object; + use std::time::{Duration, Instant}; #[derive(Clone)] struct MockProvider; @@ -992,4 +1029,74 @@ mod tests { let tool = make_tool_with_meta(Some(serde_json::json!({"ui": {"visibility": []}}))); assert!(!is_tool_visible_to_app(&tool)); } + + fn usage_with_stats(stats: Option) -> ProviderUsage { + let mut usage = ProviderUsage::new("mock".to_string(), Usage::default()); + usage.stats = stats; + usage + } + + #[test] + fn fill_stream_timing_fills_both_fields_when_stats_absent() { + let request_started = Instant::now() - Duration::from_millis(100); + let first_content_at = Some(request_started + Duration::from_millis(40)); + let mut usage = usage_with_stats(None); + + fill_stream_timing(&mut usage, request_started, first_content_at); + + let stats = usage.stats.expect("stats must be created when absent"); + assert_eq!(stats.time_to_first_token_ms, Some(40)); + let elapsed = stats.elapsed_ms.expect("elapsed_ms must be filled"); + assert!( + elapsed >= 100, + "elapsed_ms ({elapsed}) must cover the full request duration" + ); + assert!(stats.time_to_first_token_ms.unwrap() <= elapsed); + } + + #[test] + fn fill_stream_timing_preserves_provider_reported_values() { + let request_started = Instant::now() - Duration::from_millis(100); + let first_content_at = Some(request_started + Duration::from_millis(25)); + let mut usage = usage_with_stats(Some(ProviderStats { + elapsed_ms: Some(7), + time_to_first_token_ms: Some(3), + output_tokens: Some(42), + ..Default::default() + })); + + fill_stream_timing(&mut usage, request_started, first_content_at); + + let stats = usage.stats.expect("stats must survive"); + assert_eq!( + stats.elapsed_ms, + Some(7), + "provider-reported elapsed_ms (e.g. MLX) must not be overwritten" + ); + assert_eq!( + stats.time_to_first_token_ms, + Some(3), + "provider-reported TTFT must not be overwritten" + ); + assert_eq!( + stats.output_tokens, + Some(42), + "unrelated provider stats must survive the fill" + ); + } + + #[test] + fn fill_stream_timing_without_first_content_leaves_ttft_unset() { + let request_started = Instant::now() - Duration::from_millis(100); + let mut usage = usage_with_stats(None); + + fill_stream_timing(&mut usage, request_started, None); + + let stats = usage.stats.expect("stats must be created when absent"); + assert_eq!( + stats.time_to_first_token_ms, None, + "no content chunk observed means no TTFT" + ); + assert!(stats.elapsed_ms.expect("elapsed_ms must be filled") >= 100); + } } diff --git a/crates/goose/src/agents/subagent_handler.rs b/crates/goose/src/agents/subagent_handler.rs index 6c37debbb..06e0ed565 100644 --- a/crates/goose/src/agents/subagent_handler.rs +++ b/crates/goose/src/agents/subagent_handler.rs @@ -213,6 +213,7 @@ fn get_agent_messages(params: SubagentRunParams) -> AgentMessagesFuture { conversation.push(msg); } Ok(AgentEvent::Usage(_)) => {} + Ok(AgentEvent::MessageUsage { .. }) => {} Ok(AgentEvent::McpNotification(_)) => {} Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { conversation = updated_conversation; diff --git a/crates/goose/src/gateway/handler.rs b/crates/goose/src/gateway/handler.rs index 6ccf3d34a..d2123a94c 100644 --- a/crates/goose/src/gateway/handler.rs +++ b/crates/goose/src/gateway/handler.rs @@ -480,6 +480,7 @@ impl GatewayHandler { } } Ok(AgentEvent::Usage(_)) => {} + Ok(AgentEvent::MessageUsage { .. }) => {} Ok(AgentEvent::McpNotification(_)) => { tracing::debug!( session_id, diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index f7ca50c06..56305607e 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -599,6 +599,7 @@ mod tests { } Ok(AgentEvent::McpNotification(_)) => {} Ok(AgentEvent::Usage(_)) => {} + Ok(AgentEvent::MessageUsage { .. }) => {} Ok(AgentEvent::HistoryReplaced(_updated_conversation)) => { // We should update the conversation here, but we're not reading it } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 413878159..1c7d9395e 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4979,6 +4979,29 @@ } } }, + { + "type": "object", + "description": "Per-message token usage/cost, sent after the corresponding `Message`\nevent once the provider call's usage is known.", + "required": [ + "usage", + "type" + ], + "properties": { + "message_id": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "enum": [ + "MessageUsage" + ] + }, + "usage": { + "$ref": "#/components/schemas/MessageUsage" + } + } + }, { "type": "object", "required": [ diff --git a/ui/desktop/src/acp/__tests__/gooseSessionNotifications.test.ts b/ui/desktop/src/acp/__tests__/gooseSessionNotifications.test.ts new file mode 100644 index 000000000..36e0ad3a0 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/gooseSessionNotifications.test.ts @@ -0,0 +1,170 @@ +import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; +import { describe, expect, it } from 'vitest'; +import type { Message, MessageUsage } from '../../types/message'; +import { applyGooseSessionNotification } from '../adapter/gooseSessionNotifications'; +import type { AcpChatStateChange, AdapterState } from '../adapter/shared'; + +const SESSION_ID = 'session-1'; + +function gooseUpdate( + update: GooseSessionNotification_unstable['update'] +): GooseSessionNotification_unstable { + return { + sessionId: SESSION_ID, + update, + }; +} + +function message(id: string, role: Message['role']): Message { + return { + id, + role, + created: 1700000000, + content: [{ type: 'text', text: `${role} ${id}` }], + metadata: { userVisible: true, agentVisible: true }, + }; +} + +function makeState(): AdapterState { + return { + messages: [message('u1', 'user'), message('a1', 'assistant'), message('a2', 'assistant')], + localSteerTextByMessageId: new Map(), + }; +} + +const FULL_USAGE = { + inputTokens: 1200, + outputTokens: 340, + totalTokens: 1540, + cacheReadTokens: 800, + cacheWriteTokens: 100, + cost: 0.0123, + costSource: 'estimated', + elapsedMs: 4200, + timeToFirstTokenMs: 840, + isCompaction: false, +} satisfies MessageUsage; + +function messageUsageNotification( + messageId: string | undefined, + usage: MessageUsage = FULL_USAGE +): GooseSessionNotification_unstable { + return gooseUpdate({ + sessionUpdate: 'message_usage', + ...(messageId !== undefined ? { messageId } : {}), + usage, + }); +} + +function expectOnlyMessagesChange(chatStateChanges: AcpChatStateChange[]): Message[] { + expect(chatStateChanges).toHaveLength(1); + + const [chatStateChange] = chatStateChanges; + expect(chatStateChange.type).toBe('messages'); + + if (chatStateChange.type !== 'messages') { + throw new Error('expected messages state change'); + } + + return chatStateChange.messages; +} + +describe('applyGooseSessionNotification', () => { + describe('message_usage', () => { + it('attaches usage to the message matching messageId', () => { + const state = makeState(); + + const changes = applyGooseSessionNotification(state, messageUsageNotification('a1')); + + const messages = expectOnlyMessagesChange(changes); + expect(messages).toHaveLength(3); + + const [, first, second] = messages; + expect(first.id).toBe('a1'); + expect(first.metadata.usage).toEqual(FULL_USAGE); + expect(second.metadata.usage).toBeUndefined(); + + expect(state.messages[1].metadata.usage).toEqual(FULL_USAGE); + }); + + it.each([ + ['absent', undefined], + ['unknown', 'missing'], + ])('falls back to the last assistant message for an %s messageId', (_name, messageId) => { + const state = makeState(); + + const changes = applyGooseSessionNotification(state, messageUsageNotification(messageId)); + + const messages = expectOnlyMessagesChange(changes); + const [user, first, last] = messages; + expect(user.metadata.usage).toBeUndefined(); + expect(first.metadata.usage).toBeUndefined(); + expect(last.id).toBe('a2'); + expect(last.metadata.usage).toEqual(FULL_USAGE); + }); + + it('skips client-made approval rows when falling back', () => { + const state = makeState(); + state.messages.push({ + id: 'acp_permission_t1', + role: 'assistant', + created: 1700000001, + content: [ + { + type: 'actionRequired', + data: { actionType: 'toolConfirmation', id: 't1', toolName: 'shell', arguments: {} }, + }, + ], + metadata: { userVisible: true, agentVisible: true }, + }); + + const changes = applyGooseSessionNotification(state, messageUsageNotification('missing')); + + const messages = expectOnlyMessagesChange(changes); + expect(messages[3].metadata.usage).toBeUndefined(); + expect(messages[2].id).toBe('a2'); + expect(messages[2].metadata.usage).toEqual(FULL_USAGE); + }); + + it('returns no changes and mutates nothing when no assistant message exists', () => { + const state: AdapterState = { + messages: [message('u1', 'user')], + localSteerTextByMessageId: new Map(), + }; + + const changes = applyGooseSessionNotification(state, messageUsageNotification('missing')); + + expect(changes).toEqual([]); + expect(state.messages[0].metadata.usage).toBeUndefined(); + }); + }); + + describe('usage_update', () => { + it('still maps usage updates into token state', () => { + const changes = applyGooseSessionNotification( + makeState(), + gooseUpdate({ + sessionUpdate: 'usage_update', + used: 42, + contextLimit: 200, + accumulatedInputTokens: 10, + accumulatedOutputTokens: 15, + accumulatedCost: 0.12, + }) + ); + + expect(changes).toEqual([ + { + type: 'tokenState', + tokenState: { + totalTokens: 42, + accumulatedInputTokens: 10, + accumulatedOutputTokens: 15, + accumulatedTotalTokens: 25, + accumulatedCost: 0.12, + }, + }, + ]); + }); + }); +}); diff --git a/ui/desktop/src/acp/adapter/gooseSessionNotifications.ts b/ui/desktop/src/acp/adapter/gooseSessionNotifications.ts index 197b4f8a5..c7153386a 100644 --- a/ui/desktop/src/acp/adapter/gooseSessionNotifications.ts +++ b/ui/desktop/src/acp/adapter/gooseSessionNotifications.ts @@ -1,4 +1,5 @@ import type { GooseSessionNotification_unstable } from '@aaif/goose-sdk'; +import type { MessageUsage } from '../../types/message'; import { type AcpChatStateChange, type AdapterState, messagesChange } from './shared'; export function applyGooseSessionNotification( @@ -25,6 +26,8 @@ export function applyGooseSessionNotification( ]; case 'status_message': return applyStatusMessage(state, notification.sessionId, update); + case 'message_usage': + return applyMessageUsage(state, update); default: return []; } @@ -56,3 +59,46 @@ function applyStatusMessage( return messagesChange(state); } + +function applyMessageUsage( + state: AdapterState, + update: Extract +): AcpChatStateChange[] { + // Live tool-call turns carry a server-side id the client never saw, so an + // id miss falls back to the most recent assistant message with provider + // content - skipping client-made rows (approval cards, status notices). + const byId = update.messageId + ? state.messages.find((message) => message.id === update.messageId) + : undefined; + const target = + byId ?? + [...state.messages] + .reverse() + .find( + (message) => + message.role === 'assistant' && + message.content.some( + (content) => content.type !== 'actionRequired' && content.type !== 'systemNotification' + ) + ); + + if (!target) { + return []; + } + + const usage: MessageUsage = { + inputTokens: update.usage.inputTokens, + outputTokens: update.usage.outputTokens, + totalTokens: update.usage.totalTokens, + cacheReadTokens: update.usage.cacheReadTokens, + cacheWriteTokens: update.usage.cacheWriteTokens, + cost: update.usage.cost, + costSource: update.usage.costSource, + elapsedMs: update.usage.elapsedMs, + timeToFirstTokenMs: update.usage.timeToFirstTokenMs, + isCompaction: update.usage.isCompaction, + }; + + target.metadata = { ...target.metadata, usage }; + return messagesChange(state); +} diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 2bc9544f0..e2a679ad1 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -20,6 +20,7 @@ import { import ToolCallConfirmation from './ToolCallConfirmation'; import ElicitationRequest from './ElicitationRequest'; import MessageCopyLink from './MessageCopyLink'; +import MessageUsageStats from './MessageUsageStats'; import { cn } from '../utils'; import { identifyConsecutiveToolCalls, shouldHideTimestamp } from '../utils/toolCallChaining'; @@ -150,7 +151,7 @@ export default function GooseMessage({ )} {toolRequests.length === 0 && ( -
+
{!isStreaming && (
{timestamp} @@ -161,6 +162,11 @@ export default function GooseMessage({
)} + {!isStreaming && message.metadata.usage && ( +
+ +
+ )}
)}
@@ -168,7 +174,7 @@ export default function GooseMessage({ {toolRequests.length > 0 && (
-
+
{toolRequests.map((toolRequest) => { const hasResponse = toolResponsesMap.has(toolRequest.id); @@ -193,8 +199,21 @@ export default function GooseMessage({ ); })}
-
- {!isStreaming && !hideTimestamp && timestamp} +
+
+ {!isStreaming && !hideTimestamp && timestamp} +
+ {!isStreaming && message.metadata.usage && ( +
+ +
+ )}
diff --git a/ui/desktop/src/components/MessageUsageStats.test.tsx b/ui/desktop/src/components/MessageUsageStats.test.tsx new file mode 100644 index 000000000..5cf8671a1 --- /dev/null +++ b/ui/desktop/src/components/MessageUsageStats.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { MessageUsage } from '../types/message'; +import { IntlTestWrapper } from '../i18n/test-utils'; +import MessageUsageStats from './MessageUsageStats'; + +// Radix Tooltip positioning (floating-ui) needs ResizeObserver, which jsdom lacks. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +beforeAll(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +const FULL_USAGE: MessageUsage = { + inputTokens: 1200, + outputTokens: 340, + totalTokens: 1540, + cacheReadTokens: 800, + cacheWriteTokens: 100, + cost: 0.0123, + costSource: 'estimated', + elapsedMs: 4200, + timeToFirstTokenMs: 840, + isCompaction: false, +}; + +function renderUsage(usage: MessageUsage) { + return render(, { wrapper: IntlTestWrapper }); +} + +describe('MessageUsageStats', () => { + it('shows speed, estimated cost, and total tokens in the chip', () => { + renderUsage(FULL_USAGE); + + // 340 tokens over 4.2s -> 80.95 tok/s, one decimal at this magnitude. + expect(screen.getByText('81.0 tok/s')).toBeInTheDocument(); + expect(screen.getByText('~$0.012')).toBeInTheDocument(); + expect(screen.getByText('1.5k tok')).toBeInTheDocument(); + }); + + it('drops the ~ prefix for provider-reported cost', () => { + renderUsage({ ...FULL_USAGE, costSource: 'provider_reported' }); + + expect(screen.getByText('$0.012')).toBeInTheDocument(); + expect(screen.queryByText('~$0.012')).not.toBeInTheDocument(); + }); + + it('shows only the token segment when cost and timing are missing', () => { + const { container } = renderUsage({ totalTokens: 1540 }); + + expect(container).toHaveTextContent('1.5k tok'); + expect(container.textContent).not.toContain('tok/s'); + expect(container.textContent).not.toContain('$'); + expect(container.textContent).not.toContain('·'); + }); + + it('renders nothing when the usage carries no displayable data', () => { + const { container } = renderUsage({}); + expect(container).toBeEmptyDOMElement(); + + const { container: nullContainer } = renderUsage({ + inputTokens: null, + outputTokens: null, + totalTokens: null, + cost: null, + elapsedMs: null, + }); + expect(nullContainer).toBeEmptyDOMElement(); + }); + + it('breaks down tokens and cost in the tooltip on hover', async () => { + const user = userEvent.setup(); + renderUsage(FULL_USAGE); + + await user.hover(screen.getByText('1.5k tok')); + + // Radix mirrors the tooltip children into the role="tooltip" node; scoping + // there keeps queries unique (the visible popper duplicates the text). + const tooltip = within(await screen.findByRole('tooltip')); + + expect(tooltip.getByText('Input')).toBeInTheDocument(); + expect(tooltip.getByText('1.2k')).toBeInTheDocument(); + expect(tooltip.getByText('Output')).toBeInTheDocument(); + expect(tooltip.getByText('340')).toBeInTheDocument(); + expect(tooltip.getByText('(estimated)')).toBeInTheDocument(); + }); +}); diff --git a/ui/desktop/src/components/MessageUsageStats.tsx b/ui/desktop/src/components/MessageUsageStats.tsx new file mode 100644 index 000000000..5e0ead795 --- /dev/null +++ b/ui/desktop/src/components/MessageUsageStats.tsx @@ -0,0 +1,287 @@ +import { Fragment, type ReactNode } from 'react'; +import { Zap } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip'; +import { defineMessages, useIntl } from '../i18n'; +import { cn } from '../utils'; +import type { MessageUsage } from '../types/message'; +import { + formatCost, + formatDuration, + formatTokenCount, + formatTokensPerSecond, + tokensPerSecond, +} from '../utils/usageFormatting'; + +const i18n = defineMessages({ + tokensPerSecondUnit: { + id: 'messageUsageStats.tokensPerSecondUnit', + defaultMessage: 'tok/s', + }, + tokenUnit: { + id: 'messageUsageStats.tokenUnit', + defaultMessage: 'tok', + }, + input: { + id: 'messageUsageStats.input', + defaultMessage: 'Input', + }, + cacheRead: { + id: 'messageUsageStats.cacheRead', + defaultMessage: 'cache read', + }, + cacheWrite: { + id: 'messageUsageStats.cacheWrite', + defaultMessage: 'cache write', + }, + cacheHitRate: { + id: 'messageUsageStats.cacheHitRate', + defaultMessage: '({percent}% of input)', + }, + output: { + id: 'messageUsageStats.output', + defaultMessage: 'Output', + }, + total: { + id: 'messageUsageStats.total', + defaultMessage: 'Total', + }, + firstToken: { + id: 'messageUsageStats.firstToken', + defaultMessage: 'First token', + }, + totalTime: { + id: 'messageUsageStats.totalTime', + defaultMessage: 'Total time', + }, + speed: { + id: 'messageUsageStats.speed', + defaultMessage: 'Speed', + }, + cost: { + id: 'messageUsageStats.cost', + defaultMessage: 'Cost', + }, + estimated: { + id: 'messageUsageStats.estimated', + defaultMessage: '(estimated)', + }, + reported: { + id: 'messageUsageStats.reported', + defaultMessage: '(reported)', + }, + compaction: { + id: 'messageUsageStats.compaction', + defaultMessage: 'Compaction', + }, +}); + +function hasValue(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +/** One label/value row of the tooltip's stats card. */ +function StatRow({ + label, + value, + sub, +}: { + label: string; + value: ReactNode; + /** Dimmer, indented breakdown row (e.g. cache read/write). */ + sub?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +/** + * Per-message usage chip (tok/s, cost, total tokens) with a tooltip breaking + * down tokens, caching, timing, and cost. Renders nothing without data. + */ +export default function MessageUsageStats({ usage }: { usage: MessageUsage }) { + const intl = useIntl(); + const { + inputTokens, + outputTokens, + totalTokens, + cacheReadTokens, + cacheWriteTokens, + cost, + costSource, + elapsedMs, + timeToFirstTokenMs, + isCompaction, + } = usage; + + const tps = tokensPerSecond(outputTokens, elapsedMs); + + const chipSegments: ReactNode[] = []; + if (tps !== null) { + chipSegments.push( + + + ); + } + if (hasValue(cost)) { + chipSegments.push( + + {costSource === 'estimated' ? '~' : ''} + {formatCost(cost)} + + ); + } + if (hasValue(totalTokens)) { + chipSegments.push( + + {formatTokenCount(totalTokens)} {intl.formatMessage(i18n.tokenUnit)} + + ); + } + + if (chipSegments.length === 0) { + return null; + } + + const hasTokensSection = + hasValue(inputTokens) || + hasValue(outputTokens) || + hasValue(totalTokens) || + hasValue(cacheReadTokens) || + hasValue(cacheWriteTokens); + const hasTimingSection = hasValue(timeToFirstTokenMs) || hasValue(elapsedMs) || tps !== null; + const hasCostSection = hasValue(cost); + + const section = 'py-1.5 first:pt-0 last:pb-0 space-y-[3px]'; + + return ( + + +
+ {chipSegments.map((segment, index) => ( + + {index > 0 && ·} + {segment} + + ))} +
+
+ +
+ {hasTokensSection && ( +
+ {hasValue(inputTokens) && ( + + )} + {hasValue(cacheReadTokens) && ( + + {formatTokenCount(cacheReadTokens)} + {hasValue(inputTokens) && inputTokens > 0 && ( + + {' '} + {intl.formatMessage(i18n.cacheHitRate, { + percent: Math.round((cacheReadTokens / inputTokens) * 100), + })} + + )} + + } + /> + )} + {hasValue(cacheWriteTokens) && ( + + )} + {hasValue(outputTokens) && ( + + )} + {hasValue(totalTokens) && ( + + )} +
+ )} + + {hasTimingSection && ( +
+ {hasValue(timeToFirstTokenMs) && ( + + )} + {hasValue(elapsedMs) && ( + + )} + {tps !== null && ( + + )} +
+ )} + + {hasCostSection && ( +
+ {hasValue(cost) && ( + + {formatCost(cost)} + {(costSource === 'provider_reported' || costSource === 'estimated') && ( + + {' '} + {intl.formatMessage( + costSource === 'provider_reported' ? i18n.reported : i18n.estimated + )} + + )} + + } + /> + )} +
+ )} + + {isCompaction && ( +
+ {intl.formatMessage(i18n.compaction)} +
+ )} +
+
+
+ ); +} diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index 86284cbaf..71e0710e4 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "wartet" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% der Eingabe)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "Cache-Lesen" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "Cache-Schreiben" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Verdichtung" + }, + "messageUsageStats.cost": { + "defaultMessage": "Kosten" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(geschätzt)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Erstes Token" + }, + "messageUsageStats.input": { + "defaultMessage": "Eingabe" + }, + "messageUsageStats.output": { + "defaultMessage": "Ausgabe" + }, + "messageUsageStats.reported": { + "defaultMessage": "(gemeldet)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Geschwindigkeit" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Gesamt" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Gesamtzeit" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Wählen Sie aus, welches Mikrofon für das Diktat verwendet werden soll" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 1218b3c90..a9db75640 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "waiting" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% of input)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "cache read" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "cache write" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Compaction" + }, + "messageUsageStats.cost": { + "defaultMessage": "Cost" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(estimated)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "First token" + }, + "messageUsageStats.input": { + "defaultMessage": "Input" + }, + "messageUsageStats.output": { + "defaultMessage": "Output" + }, + "messageUsageStats.reported": { + "defaultMessage": "(reported)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Speed" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Total" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Total time" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Choose which microphone to use for dictation" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index 2f79a6ef0..3eba12aeb 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "esperando" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% de la entrada)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "lectura de caché" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "escritura de caché" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Compactación" + }, + "messageUsageStats.cost": { + "defaultMessage": "Costo" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(estimado)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Primer token" + }, + "messageUsageStats.input": { + "defaultMessage": "Entrada" + }, + "messageUsageStats.output": { + "defaultMessage": "Salida" + }, + "messageUsageStats.reported": { + "defaultMessage": "(reportado)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Velocidad" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Total" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Tiempo total" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Elige qué micrófono usar para el dictado" }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index af43762c0..e0749c7d1 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "en attente" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent} % de l'entrée)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "lecture du cache" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "écriture du cache" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Compactage" + }, + "messageUsageStats.cost": { + "defaultMessage": "Coût" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(estimé)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Premier token" + }, + "messageUsageStats.input": { + "defaultMessage": "Entrée" + }, + "messageUsageStats.output": { + "defaultMessage": "Sortie" + }, + "messageUsageStats.reported": { + "defaultMessage": "(rapporté)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Vitesse" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Total" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Temps total" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Choisissez le microphone à utiliser pour la dictée" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index 97d03c209..bcf4c8cb9 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "इंतज़ार कर रहा हूँ" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "(इनपुट का {percent}%)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "कैश रीड" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "कैश राइट" + }, + "messageUsageStats.compaction": { + "defaultMessage": "संक्षिप्तीकरण" + }, + "messageUsageStats.cost": { + "defaultMessage": "लागत" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(अनुमानित)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "पहला टोकन" + }, + "messageUsageStats.input": { + "defaultMessage": "इनपुट" + }, + "messageUsageStats.output": { + "defaultMessage": "आउटपुट" + }, + "messageUsageStats.reported": { + "defaultMessage": "(रिपोर्ट किया गया)" + }, + "messageUsageStats.speed": { + "defaultMessage": "गति" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "कुल" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "कुल समय" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "श्रुतलेख के लिए कौन सा माइक्रोफ़ोन उपयोग करना है चुनें" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index 4c32691da..29ec94cae 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "menunggu" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% dari masukan)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "baca cache" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "tulis cache" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Pemadatan" + }, + "messageUsageStats.cost": { + "defaultMessage": "Biaya" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(perkiraan)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Token pertama" + }, + "messageUsageStats.input": { + "defaultMessage": "Masukan" + }, + "messageUsageStats.output": { + "defaultMessage": "Keluaran" + }, + "messageUsageStats.reported": { + "defaultMessage": "(dilaporkan)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Kecepatan" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Total" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Waktu total" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Pilih mikrofon mana yang akan digunakan untuk dikte" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index 6cc0c568e..f152a496f 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "in attesa" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% dell'input)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "lettura cache" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "scrittura cache" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Compattazione" + }, + "messageUsageStats.cost": { + "defaultMessage": "Costo" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(stimato)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Primo token" + }, + "messageUsageStats.input": { + "defaultMessage": "Input" + }, + "messageUsageStats.output": { + "defaultMessage": "Output" + }, + "messageUsageStats.reported": { + "defaultMessage": "(riportato)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Velocità" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Totale" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Tempo totale" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Scegli quale microfono usare per la dettatura" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index 502fe5ff1..464c05f13 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "待機中" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "(入力の{percent}%)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "キャッシュ読み取り" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "キャッシュ書き込み" + }, + "messageUsageStats.compaction": { + "defaultMessage": "圧縮" + }, + "messageUsageStats.cost": { + "defaultMessage": "コスト" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(推定)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "最初のトークン" + }, + "messageUsageStats.input": { + "defaultMessage": "入力" + }, + "messageUsageStats.output": { + "defaultMessage": "出力" + }, + "messageUsageStats.reported": { + "defaultMessage": "(報告値)" + }, + "messageUsageStats.speed": { + "defaultMessage": "速度" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "合計" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "合計時間" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "音声入力に使用するマイクを選択" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 3916658c8..be7a352c8 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "대기 중" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "(입력의 {percent}%)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "캐시 읽기" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "캐시 쓰기" + }, + "messageUsageStats.compaction": { + "defaultMessage": "압축" + }, + "messageUsageStats.cost": { + "defaultMessage": "비용" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(추정)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "첫 토큰" + }, + "messageUsageStats.input": { + "defaultMessage": "입력" + }, + "messageUsageStats.output": { + "defaultMessage": "출력" + }, + "messageUsageStats.reported": { + "defaultMessage": "(보고됨)" + }, + "messageUsageStats.speed": { + "defaultMessage": "속도" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "합계" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "총 시간" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "받아쓰기에 사용할 마이크 선택" }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 205a40b9a..90dfd3a54 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "menunggu" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% daripada input)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "baca cache" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "tulis cache" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Pemadatan" + }, + "messageUsageStats.cost": { + "defaultMessage": "Kos" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(anggaran)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Token pertama" + }, + "messageUsageStats.input": { + "defaultMessage": "Input" + }, + "messageUsageStats.output": { + "defaultMessage": "Output" + }, + "messageUsageStats.reported": { + "defaultMessage": "(dilaporkan)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Kelajuan" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Jumlah" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Jumlah masa" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Pilih mikrofon yang hendak digunakan untuk imlak" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index 9e4edd8b2..27fb5ae24 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "aguardando" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% da entrada)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "leitura de cache" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "escrita de cache" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Compactação" + }, + "messageUsageStats.cost": { + "defaultMessage": "Custo" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(estimado)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Primeiro token" + }, + "messageUsageStats.input": { + "defaultMessage": "Entrada" + }, + "messageUsageStats.output": { + "defaultMessage": "Saída" + }, + "messageUsageStats.reported": { + "defaultMessage": "(informado)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Velocidade" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Total" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Tempo total" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Escolha qual microfone usar para o ditado" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 406f75efe..336db6b72 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "ожидание" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% от ввода)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "чтение кэша" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "запись кэша" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Сжатие" + }, + "messageUsageStats.cost": { + "defaultMessage": "Стоимость" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(оценочно)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Первый токен" + }, + "messageUsageStats.input": { + "defaultMessage": "Ввод" + }, + "messageUsageStats.output": { + "defaultMessage": "Вывод" + }, + "messageUsageStats.reported": { + "defaultMessage": "(заявлено)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Скорость" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Всего" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Общее время" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Выберите микрофон для диктовки" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index 93c079a2b..4eb714748 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "beklemek" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "(girdinin %{percent}'i)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "önbellek okuma" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "önbellek yazma" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Sıkıştırma" + }, + "messageUsageStats.cost": { + "defaultMessage": "Maliyet" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(tahmini)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "İlk jeton" + }, + "messageUsageStats.input": { + "defaultMessage": "Girdi" + }, + "messageUsageStats.output": { + "defaultMessage": "Çıktı" + }, + "messageUsageStats.reported": { + "defaultMessage": "(bildirilen)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Hız" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Toplam" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Toplam süre" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Dikte için hangi mikrofonun kullanılacağını seçin" }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index 9fbce143e..eb6f43f6b 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "đang chờ" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "({percent}% đầu vào)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "đọc cache" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "ghi cache" + }, + "messageUsageStats.compaction": { + "defaultMessage": "Nén" + }, + "messageUsageStats.cost": { + "defaultMessage": "Chi phí" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(ước tính)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "Token đầu tiên" + }, + "messageUsageStats.input": { + "defaultMessage": "Đầu vào" + }, + "messageUsageStats.output": { + "defaultMessage": "Đầu ra" + }, + "messageUsageStats.reported": { + "defaultMessage": "(được báo cáo)" + }, + "messageUsageStats.speed": { + "defaultMessage": "Tốc độ" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "Tổng" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "Tổng thời gian" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "Chọn micrô nào để sử dụng cho việc đọc chính tả" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index fb1a51620..e279e514b 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "等待中" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "(占输入的 {percent}%)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "缓存读取" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "缓存写入" + }, + "messageUsageStats.compaction": { + "defaultMessage": "压缩" + }, + "messageUsageStats.cost": { + "defaultMessage": "费用" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(估算)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "首个令牌" + }, + "messageUsageStats.input": { + "defaultMessage": "输入" + }, + "messageUsageStats.output": { + "defaultMessage": "输出" + }, + "messageUsageStats.reported": { + "defaultMessage": "(上报)" + }, + "messageUsageStats.speed": { + "defaultMessage": "速度" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "总计" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "总耗时" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "选择用于语音输入的麦克风" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index 47b8f7b2d..4c1be38fa 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -2012,6 +2012,51 @@ "messageQueue.waiting": { "defaultMessage": "等待中" }, + "messageUsageStats.cacheHitRate": { + "defaultMessage": "(佔輸入的 {percent}%)" + }, + "messageUsageStats.cacheRead": { + "defaultMessage": "快取讀取" + }, + "messageUsageStats.cacheWrite": { + "defaultMessage": "快取寫入" + }, + "messageUsageStats.compaction": { + "defaultMessage": "壓縮" + }, + "messageUsageStats.cost": { + "defaultMessage": "費用" + }, + "messageUsageStats.estimated": { + "defaultMessage": "(估算)" + }, + "messageUsageStats.firstToken": { + "defaultMessage": "首個權杖" + }, + "messageUsageStats.input": { + "defaultMessage": "輸入" + }, + "messageUsageStats.output": { + "defaultMessage": "輸出" + }, + "messageUsageStats.reported": { + "defaultMessage": "(回報)" + }, + "messageUsageStats.speed": { + "defaultMessage": "速度" + }, + "messageUsageStats.tokenUnit": { + "defaultMessage": "tok" + }, + "messageUsageStats.tokensPerSecondUnit": { + "defaultMessage": "tok/s" + }, + "messageUsageStats.total": { + "defaultMessage": "總計" + }, + "messageUsageStats.totalTime": { + "defaultMessage": "總時間" + }, "microphoneSelector.chooseDescription": { "defaultMessage": "選擇要用於聽寫的麥克風" }, diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index 840f65895..c7fb58b1b 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -166,10 +166,25 @@ export type InferenceMetadata = { resolvedModel?: string | null; }; +/** Mirrors the backend `MessageUsage` schema (camelCase). */ +export type MessageUsage = { + inputTokens?: number | null; + outputTokens?: number | null; + totalTokens?: number | null; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; + cost?: number | null; + costSource?: 'provider_reported' | 'estimated' | null; + elapsedMs?: number | null; + timeToFirstTokenMs?: number | null; + isCompaction?: boolean; +}; + export type MessageMetadata = { agentVisible: boolean; inference?: InferenceMetadata | null; steer?: boolean; + usage?: MessageUsage | null; userVisible: boolean; }; @@ -201,6 +216,11 @@ export type MessageEvent = token_state: TokenState; type: 'Message'; } + | { + message_id?: string | null; + usage: MessageUsage; + type: 'MessageUsage'; + } | { error: string; type: 'Error'; diff --git a/ui/desktop/src/utils/usageFormatting.test.ts b/ui/desktop/src/utils/usageFormatting.test.ts new file mode 100644 index 000000000..3788e7ff2 --- /dev/null +++ b/ui/desktop/src/utils/usageFormatting.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + formatCost, + formatDuration, + formatTokenCount, + formatTokensPerSecond, + tokensPerSecond, +} from './usageFormatting'; + +describe('usageFormatting', () => { + describe('formatTokenCount', () => { + it.each([ + [999, '999'], + [1000, '1k'], + [1234, '1.2k'], + [12345, '12k'], + [999_499, '999k'], + [1_200_000, '1.2M'], + ])('formats %d as %s', (input, expected) => { + expect(formatTokenCount(input)).toBe(expected); + }); + }); + + describe('formatCost', () => { + it.each([ + [0, '$0.00'], + [1.236, '$1.24'], + [0.0123, '$0.012'], + [0.0004, '$0.0004'], + ])('formats %d as %s', (input, expected) => { + expect(formatCost(input)).toBe(expected); + }); + }); + + describe('formatDuration', () => { + it.each([ + [840, '840ms'], + [1000, '1s'], + [4200, '4.2s'], + [65_000, '1m 5s'], + ])('formats %dms as %s', (input, expected) => { + expect(formatDuration(input)).toBe(expected); + }); + }); + + describe('tokensPerSecond', () => { + it('computes output tokens over elapsed seconds', () => { + expect(tokensPerSecond(340, 4200)).toBeCloseTo(80.95, 2); + }); + + it.each([ + ['elapsedMs 0', 340, 0], + ['elapsedMs null', 340, null], + ['elapsedMs undefined', 340, undefined], + ['outputTokens 0', 0, 4200], + ['outputTokens null', null, 4200], + ])('returns null for %s', (_name, outputTokens, elapsedMs) => { + expect(tokensPerSecond(outputTokens, elapsedMs)).toBeNull(); + }); + }); + + describe('formatTokensPerSecond', () => { + it.each([ + [128.4, '128'], + [48.23, '48.2'], + [3.751, '3.75'], + [100, '100'], + [10, '10.0'], + ])('formats %d as %s', (input, expected) => { + expect(formatTokensPerSecond(input)).toBe(expected); + }); + }); +}); diff --git a/ui/desktop/src/utils/usageFormatting.ts b/ui/desktop/src/utils/usageFormatting.ts new file mode 100644 index 000000000..a694970cb --- /dev/null +++ b/ui/desktop/src/utils/usageFormatting.ts @@ -0,0 +1,55 @@ +// Pure formatting helpers for per-message usage stats. Unit labels are left to +// the consuming component so they can be localized. + +/** `842`, `1.2k`, `12k`, `1.2M` — one decimal below 10k/10M, trailing `.0` stripped. */ +export function formatTokenCount(n: number): string { + if (n < 1000) { + return Math.round(n).toString(); + } + const [value, suffix] = n >= 1_000_000 ? [n / 1_000_000, 'M'] : [n / 1000, 'k']; + const text = value < 10 ? value.toFixed(1).replace(/\.0$/, '') : Math.round(value).toString(); + return `${text}${suffix}`; +} + +/** Adaptive precision so small per-message costs stay meaningful: `$1.24`, `$0.012`, `$0.0004`. */ +export function formatCost(cost: number): string { + if (cost === 0) { + return '$0.00'; + } + const decimals = cost >= 1 ? 2 : cost >= 0.01 ? 3 : 4; + return `$${cost.toFixed(decimals)}`; +} + +/** `840ms` below 1s, `4.2s` below 1m, else `1m 5s`. */ +export function formatDuration(ms: number): string { + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + if (ms < 60_000) { + return `${(ms / 1000).toFixed(1).replace(/\.0$/, '')}s`; + } + const totalSeconds = Math.round(ms / 1000); + return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`; +} + +/** Tokens per second, or null when either input is missing or non-positive (no NaN/Infinity). */ +export function tokensPerSecond( + outputTokens: number | null | undefined, + elapsedMs: number | null | undefined +): number | null { + if (!outputTokens || !elapsedMs || outputTokens <= 0 || elapsedMs <= 0) { + return null; + } + return outputTokens / (elapsedMs / 1000); +} + +/** Precision scaled to magnitude: `128`, `48.2`, `3.75`. */ +export function formatTokensPerSecond(tps: number): string { + if (tps >= 100) { + return Math.round(tps).toString(); + } + if (tps >= 10) { + return tps.toFixed(1); + } + return tps.toFixed(2); +} diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 5cb5dec99..d866ddd50 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, AgentMention, Annotations, AppsExportRequest_unstable, AppsExportResponse_unstable, AppsImportRequest_unstable, AppsImportResponse_unstable, AppsListRequest_unstable, AppsListResponse_unstable, ArchiveSessionRequest_unstable, AudioContent, AvailableCommand, AvailableCommandInput, BlobResourceContents, CanonicalModelInfoDto, CanonicalModelInfoRequest_unstable, CanonicalModelInfoResponse_unstable, ConfigReadAllRequest_unstable, ConfigReadAllResponse_unstable, ConfigReadRequest_unstable, ConfigReadResponse_unstable, ConfigRemoveRequest_unstable, ConfigUpsertRequest_unstable, ContentBlock, CreateScheduleRequest_unstable, CreateScheduleResponse_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DecodeRecipeRequest_unstable, DecodeRecipeResponse_unstable, DefaultsClearRequest_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteRecipeRequest_unstable, DeleteScheduleRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DiagnosticsGetRequest_unstable, DiagnosticsGetResponse_unstable, DiagnosticsReportLevel, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EncodeRecipeRequest_unstable, EncodeRecipeResponse_unstable, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtAgentRequest, ExtAgentResponse, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetPromptRequest_unstable, GetPromptResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, InspectRunningJobRequest_unstable, InspectRunningJobResponse_unstable, KillRunningJobRequest_unstable, KillRunningJobResponse_unstable, ListAgentMentionsRequest_unstable, ListAgentMentionsResponse_unstable, ListPromptsRequest_unstable, ListPromptsResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListRecipesRequest_unstable, ListRecipesResponse_unstable, ListScheduleSessionsRequest_unstable, ListScheduleSessionsResponse_unstable, ListSchedulesRequest_unstable, ListSchedulesResponse_unstable, ListSlashCommandsRequest_unstable, ListSlashCommandsResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, LocalInferenceBuiltinChatTemplatesListRequest_unstable, LocalInferenceBuiltinChatTemplatesListResponse_unstable, LocalInferenceChatTemplate, LocalInferenceDownloadProgressDto, LocalInferenceDownloadState, LocalInferenceHfGgufFileDto, LocalInferenceHfModelInfoDto, LocalInferenceHfModelVariantDto, LocalInferenceHuggingFaceRepoVariantsRequest_unstable, LocalInferenceHuggingFaceRepoVariantsResponse_unstable, LocalInferenceHuggingFaceSearchRequest_unstable, LocalInferenceHuggingFaceSearchResponse_unstable, LocalInferenceModelDeleteRequest_unstable, LocalInferenceModelDownloadCancelRequest_unstable, LocalInferenceModelDownloadProgressRequest_unstable, LocalInferenceModelDownloadProgressResponse_unstable, LocalInferenceModelDownloadRequest_unstable, LocalInferenceModelDownloadResponse_unstable, LocalInferenceModelDownloadStatusDto, LocalInferenceModelDto, LocalInferenceModelSettingsDto, LocalInferenceModelSettingsReadRequest_unstable, LocalInferenceModelSettingsReadResponse_unstable, LocalInferenceModelSettingsUpdateRequest_unstable, LocalInferenceModelSettingsUpdateResponse_unstable, LocalInferenceModelsListRequest_unstable, LocalInferenceModelsListResponse_unstable, LocalInferenceSamplingConfig, LocalInferenceToolCallingMode, McpServer, McpServerAcp, McpServerAcpId, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, ParseRecipeRequest_unstable, ParseRecipeResponse_unstable, PauseScheduleRequest_unstable, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, PromptOperationResponse_unstable, PromptTemplateEntry, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSecretDeleteRequest_unstable, ProviderSecretDto, ProviderSecretsListRequest_unstable, ProviderSecretsListResponse_unstable, ProviderSecretStatusDto, ProviderSecretStorageDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeListEntryDto, RecipeParameterDto, RecipeParameterInputTypeDto, RecipeParameterRequirementDto, RecipeParamsAction, RecipeParamsResponse_unstable, RecipeResponseDto, RecipeRetryConfigDto, RecipeSettingsDto, RecipeSuccessCheckDto, RecipeToYamlRequest_unstable, RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, ResetPromptRequest_unstable, ResourceLink, Role, RunScheduleNowRequest_unstable, RunScheduleNowResponse_unstable, RunScheduleNowStatus, SavePromptRequest_unstable, SaveRecipeRequest_unstable, SaveRecipeResponse_unstable, ScanRecipeRequest_unstable, ScanRecipeResponse_unstable, ScheduledJobDto, ScheduleRecipeRequest_unstable, SessionId, SessionImportSource, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetRecipeSlashCommandRequest_unstable, SetSessionSystemPromptRequest_unstable, SetToolPermissionsRequest_unstable, SetToolPermissionsResponse_unstable, ShareSessionNostrRequest_unstable, ShareSessionNostrResponse_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, SubRecipeDto, TextContent, TextResourceContents, ToolListItem, ToolPermissionEntry, ToolPermissionLevel, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UnpauseScheduleRequest_unstable, UnstructuredCommandInput, UpdateScheduleRequest_unstable, UpdateScheduleResponse_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, AgentMention, Annotations, AppsExportRequest_unstable, AppsExportResponse_unstable, AppsImportRequest_unstable, AppsImportResponse_unstable, AppsListRequest_unstable, AppsListResponse_unstable, ArchiveSessionRequest_unstable, AudioContent, AvailableCommand, AvailableCommandInput, BlobResourceContents, CanonicalModelInfoDto, CanonicalModelInfoRequest_unstable, CanonicalModelInfoResponse_unstable, ConfigReadAllRequest_unstable, ConfigReadAllResponse_unstable, ConfigReadRequest_unstable, ConfigReadResponse_unstable, ConfigRemoveRequest_unstable, ConfigUpsertRequest_unstable, ContentBlock, CostSourceData, CreateScheduleRequest_unstable, CreateScheduleResponse_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DecodeRecipeRequest_unstable, DecodeRecipeResponse_unstable, DefaultsClearRequest_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteRecipeRequest_unstable, DeleteScheduleRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DiagnosticsGetRequest_unstable, DiagnosticsGetResponse_unstable, DiagnosticsReportLevel, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EncodeRecipeRequest_unstable, EncodeRecipeResponse_unstable, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtAgentRequest, ExtAgentResponse, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetPromptRequest_unstable, GetPromptResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, InspectRunningJobRequest_unstable, InspectRunningJobResponse_unstable, KillRunningJobRequest_unstable, KillRunningJobResponse_unstable, ListAgentMentionsRequest_unstable, ListAgentMentionsResponse_unstable, ListPromptsRequest_unstable, ListPromptsResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListRecipesRequest_unstable, ListRecipesResponse_unstable, ListScheduleSessionsRequest_unstable, ListScheduleSessionsResponse_unstable, ListSchedulesRequest_unstable, ListSchedulesResponse_unstable, ListSlashCommandsRequest_unstable, ListSlashCommandsResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, LocalInferenceBuiltinChatTemplatesListRequest_unstable, LocalInferenceBuiltinChatTemplatesListResponse_unstable, LocalInferenceChatTemplate, LocalInferenceDownloadProgressDto, LocalInferenceDownloadState, LocalInferenceHfGgufFileDto, LocalInferenceHfModelInfoDto, LocalInferenceHfModelVariantDto, LocalInferenceHuggingFaceRepoVariantsRequest_unstable, LocalInferenceHuggingFaceRepoVariantsResponse_unstable, LocalInferenceHuggingFaceSearchRequest_unstable, LocalInferenceHuggingFaceSearchResponse_unstable, LocalInferenceModelDeleteRequest_unstable, LocalInferenceModelDownloadCancelRequest_unstable, LocalInferenceModelDownloadProgressRequest_unstable, LocalInferenceModelDownloadProgressResponse_unstable, LocalInferenceModelDownloadRequest_unstable, LocalInferenceModelDownloadResponse_unstable, LocalInferenceModelDownloadStatusDto, LocalInferenceModelDto, LocalInferenceModelSettingsDto, LocalInferenceModelSettingsReadRequest_unstable, LocalInferenceModelSettingsReadResponse_unstable, LocalInferenceModelSettingsUpdateRequest_unstable, LocalInferenceModelSettingsUpdateResponse_unstable, LocalInferenceModelsListRequest_unstable, LocalInferenceModelsListResponse_unstable, LocalInferenceSamplingConfig, LocalInferenceToolCallingMode, McpServer, McpServerAcp, McpServerAcpId, McpServerHttp, McpServerSse, McpServerStdio, MessageUsageData, MessageUsageUpdate, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, ParseRecipeRequest_unstable, ParseRecipeResponse_unstable, PauseScheduleRequest_unstable, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, PromptOperationResponse_unstable, PromptTemplateEntry, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSecretDeleteRequest_unstable, ProviderSecretDto, ProviderSecretsListRequest_unstable, ProviderSecretsListResponse_unstable, ProviderSecretStatusDto, ProviderSecretStorageDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeListEntryDto, RecipeParameterDto, RecipeParameterInputTypeDto, RecipeParameterRequirementDto, RecipeParamsAction, RecipeParamsResponse_unstable, RecipeResponseDto, RecipeRetryConfigDto, RecipeSettingsDto, RecipeSuccessCheckDto, RecipeToYamlRequest_unstable, RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, ResetPromptRequest_unstable, ResourceLink, Role, RunScheduleNowRequest_unstable, RunScheduleNowResponse_unstable, RunScheduleNowStatus, SavePromptRequest_unstable, SaveRecipeRequest_unstable, SaveRecipeResponse_unstable, ScanRecipeRequest_unstable, ScanRecipeResponse_unstable, ScheduledJobDto, ScheduleRecipeRequest_unstable, SessionId, SessionImportSource, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetRecipeSlashCommandRequest_unstable, SetSessionSystemPromptRequest_unstable, SetToolPermissionsRequest_unstable, SetToolPermissionsResponse_unstable, ShareSessionNostrRequest_unstable, ShareSessionNostrResponse_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, SubRecipeDto, TextContent, TextResourceContents, ToolListItem, ToolPermissionEntry, ToolPermissionLevel, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UnpauseScheduleRequest_unstable, UnstructuredCommandInput, UpdateScheduleRequest_unstable, UpdateScheduleResponse_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index f6f71e336..96c9ad0dd 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -2533,7 +2533,9 @@ export type GooseSessionUpdate = ({ sessionUpdate: 'usage_update'; } & SessionUsageUpdate) | ({ sessionUpdate: 'status_message'; -} & StatusMessageUpdate); +} & StatusMessageUpdate) | ({ + sessionUpdate: 'message_usage'; +} & MessageUsageUpdate); /** * Streaming context-window usage update for a session. @@ -2562,6 +2564,43 @@ export type StatusMessageUpdate = { status: StatusMessage; }; +/** + * Wire mirror of the conversation `MessageUsage` (this crate cannot depend on + * goose-provider-types); field names and serde casing MUST stay in parity. + */ +export type MessageUsageData = { + inputTokens?: number | null; + outputTokens?: number | null; + totalTokens?: number | null; + cacheReadTokens?: number | null; + cacheWriteTokens?: number | null; + cost?: number | null; + costSource?: CostSourceData | null; + /** + * Wall-clock generation time, used by the client for a tokens/sec readout. + */ + elapsedMs?: number | null; + timeToFirstTokenMs?: number | null; + /** + * Usage from a compaction/summarization call rather than a normal turn. + */ + isCompaction?: boolean; +}; + +/** + * Wire mirror of the conversation `CostSource`. + */ +export type CostSourceData = 'provider_reported' | 'estimated'; + +/** + * Per-message token usage/cost/timing, keyed by the message id used for + * chunk matching. Sent live after a turn's messages and on replay. + */ +export type MessageUsageUpdate = { + messageId?: string | null; + usage: MessageUsageData; +}; + export type RequestRecipeParams_unstable = { sessionId: string; parameters: Array; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 2ff020ffc..9ccf5837f 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -2698,6 +2698,70 @@ export const zStatusMessageUpdate = z.object({ status: zStatusMessage }); +/** + * Wire mirror of the conversation `CostSource`. + */ +export const zCostSourceData = z.union([ + z.literal('provider_reported'), + z.literal('estimated') +]); + +/** + * Wire mirror of the conversation `MessageUsage` (this crate cannot depend on + * goose-provider-types); field names and serde casing MUST stay in parity. + */ +export const zMessageUsageData = z.object({ + inputTokens: z.union([ + z.number().int(), + z.null() + ]).optional(), + outputTokens: z.union([ + z.number().int(), + z.null() + ]).optional(), + totalTokens: z.union([ + z.number().int(), + z.null() + ]).optional(), + cacheReadTokens: z.union([ + z.number().int(), + z.null() + ]).optional(), + cacheWriteTokens: z.union([ + z.number().int(), + z.null() + ]).optional(), + cost: z.union([ + z.number(), + z.null() + ]).optional(), + costSource: z.union([ + zCostSourceData, + z.null() + ]).optional(), + elapsedMs: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + timeToFirstTokenMs: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + isCompaction: z.boolean().optional().default(false) +}); + +/** + * Per-message token usage/cost/timing, keyed by the message id used for + * chunk matching. Sent live after a turn's messages and on replay. + */ +export const zMessageUsageUpdate = z.object({ + messageId: z.union([ + z.string(), + z.null() + ]).optional(), + usage: zMessageUsageData +}); + /** * Discriminated union of goose-specific session update payloads. * Variant tag matches ACP's convention (`sessionUpdate: ""`). @@ -2712,7 +2776,10 @@ export const zGooseSessionUpdate = z.union([ }).and(zSessionUsageUpdate), z.object({ sessionUpdate: z.literal('status_message') - }).and(zStatusMessageUpdate) + }).and(zStatusMessageUpdate), + z.object({ + sessionUpdate: z.literal('message_usage') + }).and(zMessageUsageUpdate) ]); /**