feat(desktop): per-message usage stats UI (tokens, cost, TTFT, tok/s) (#10210)
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
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<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_tokens: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_tokens: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_read_tokens: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_write_tokens: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost_source: Option<CostSourceData>,
|
||||
/// Wall-clock generation time, used by the client for a tokens/sec readout.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub elapsed_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub time_to_first_token_ms: Option<u64>,
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: \"<snake_case>\"`).\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": {
|
||||
|
||||
@@ -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<String>,
|
||||
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(
|
||||
|
||||
@@ -29,6 +29,7 @@ fn send_replay_content_chunk(
|
||||
fn replay_conversation_to_client(
|
||||
cx: &ConnectionTo<Client>,
|
||||
session: &Session,
|
||||
supports_goose_custom_notifications: bool,
|
||||
) -> Result<HashMap<String, crate::conversation::message::ToolRequest>, 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)
|
||||
|
||||
@@ -265,19 +265,26 @@ pub struct Agent {
|
||||
pub enum AgentEvent {
|
||||
Message(Message),
|
||||
Usage(crate::providers::base::ProviderUsage),
|
||||
MessageUsage {
|
||||
message_id: Option<String>,
|
||||
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<String>, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<std::time::Instant>,
|
||||
) {
|
||||
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<Message> = None;
|
||||
let mut final_usage: Option<ProviderUsage> = None;
|
||||
let mut first_content_at: Option<std::time::Instant> = 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<std::time::Instant> = 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<ProviderStats>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -480,6 +480,7 @@ impl GatewayHandler {
|
||||
}
|
||||
}
|
||||
Ok(AgentEvent::Usage(_)) => {}
|
||||
Ok(AgentEvent::MessageUsage { .. }) => {}
|
||||
Ok(AgentEvent::McpNotification(_)) => {
|
||||
tracing::debug!(
|
||||
session_id,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<GooseSessionNotification_unstable['update'], { sessionUpdate: 'message_usage' }>
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<div className="relative flex justify-start">
|
||||
<div className="relative flex items-center justify-between">
|
||||
{!isStreaming && (
|
||||
<div className="text-xs font-mono text-text-secondary pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
||||
{timestamp}
|
||||
@@ -161,6 +162,11 @@ export default function GooseMessage({
|
||||
<MessageCopyLink text={displayText} contentRef={contentRef} />
|
||||
</div>
|
||||
)}
|
||||
{!isStreaming && message.metadata.usage && (
|
||||
<div className="pt-1 transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0">
|
||||
<MessageUsageStats usage={message.metadata.usage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -168,7 +174,7 @@ export default function GooseMessage({
|
||||
|
||||
{toolRequests.length > 0 && (
|
||||
<div className={cn(displayText && 'mt-2')}>
|
||||
<div className="relative flex flex-col w-full">
|
||||
<div className="relative flex flex-col w-full group">
|
||||
<div className="flex flex-col gap-3">
|
||||
{toolRequests.map((toolRequest) => {
|
||||
const hasResponse = toolResponsesMap.has(toolRequest.id);
|
||||
@@ -193,8 +199,21 @@ export default function GooseMessage({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0 pt-1">
|
||||
{!isStreaming && !hideTimestamp && timestamp}
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs text-text-secondary pt-1',
|
||||
message.metadata.usage &&
|
||||
'transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0'
|
||||
)}
|
||||
>
|
||||
{!isStreaming && !hideTimestamp && timestamp}
|
||||
</div>
|
||||
{!isStreaming && message.metadata.usage && (
|
||||
<div className="pt-1 transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0">
|
||||
<MessageUsageStats usage={message.metadata.usage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(<MessageUsageStats usage={usage} />, { 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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className={cn('flex items-baseline justify-between gap-8', sub && 'pl-3')}>
|
||||
<span className={cn('text-text-inverse/55', sub && 'text-text-inverse/40')}>{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono tabular-nums text-right',
|
||||
sub ? 'text-text-inverse/60' : 'text-text-inverse/90'
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
<span key="tps" className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" aria-hidden="true" />
|
||||
{formatTokensPerSecond(tps)} {intl.formatMessage(i18n.tokensPerSecondUnit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (hasValue(cost)) {
|
||||
chipSegments.push(
|
||||
<span key="cost">
|
||||
{costSource === 'estimated' ? '~' : ''}
|
||||
{formatCost(cost)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (hasValue(totalTokens)) {
|
||||
chipSegments.push(
|
||||
<span key="tokens">
|
||||
{formatTokenCount(totalTokens)} {intl.formatMessage(i18n.tokenUnit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs font-mono text-text-secondary hover:text-text-primary transition-colors cursor-default">
|
||||
{chipSegments.map((segment, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && <span className="text-text-tertiary">·</span>}
|
||||
{segment}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="end" className="px-3.5 py-2.5 rounded-lg text-left">
|
||||
<div className="min-w-44 text-xs divide-y divide-text-inverse/10">
|
||||
{hasTokensSection && (
|
||||
<div className={section}>
|
||||
{hasValue(inputTokens) && (
|
||||
<StatRow
|
||||
label={intl.formatMessage(i18n.input)}
|
||||
value={formatTokenCount(inputTokens)}
|
||||
/>
|
||||
)}
|
||||
{hasValue(cacheReadTokens) && (
|
||||
<StatRow
|
||||
sub
|
||||
label={intl.formatMessage(i18n.cacheRead)}
|
||||
value={
|
||||
<>
|
||||
{formatTokenCount(cacheReadTokens)}
|
||||
{hasValue(inputTokens) && inputTokens > 0 && (
|
||||
<span className="text-text-inverse/40">
|
||||
{' '}
|
||||
{intl.formatMessage(i18n.cacheHitRate, {
|
||||
percent: Math.round((cacheReadTokens / inputTokens) * 100),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasValue(cacheWriteTokens) && (
|
||||
<StatRow
|
||||
sub
|
||||
label={intl.formatMessage(i18n.cacheWrite)}
|
||||
value={formatTokenCount(cacheWriteTokens)}
|
||||
/>
|
||||
)}
|
||||
{hasValue(outputTokens) && (
|
||||
<StatRow
|
||||
label={intl.formatMessage(i18n.output)}
|
||||
value={formatTokenCount(outputTokens)}
|
||||
/>
|
||||
)}
|
||||
{hasValue(totalTokens) && (
|
||||
<StatRow
|
||||
label={intl.formatMessage(i18n.total)}
|
||||
value={formatTokenCount(totalTokens)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasTimingSection && (
|
||||
<div className={section}>
|
||||
{hasValue(timeToFirstTokenMs) && (
|
||||
<StatRow
|
||||
label={intl.formatMessage(i18n.firstToken)}
|
||||
value={formatDuration(timeToFirstTokenMs)}
|
||||
/>
|
||||
)}
|
||||
{hasValue(elapsedMs) && (
|
||||
<StatRow
|
||||
label={intl.formatMessage(i18n.totalTime)}
|
||||
value={formatDuration(elapsedMs)}
|
||||
/>
|
||||
)}
|
||||
{tps !== null && (
|
||||
<StatRow
|
||||
label={intl.formatMessage(i18n.speed)}
|
||||
value={`${formatTokensPerSecond(tps)} ${intl.formatMessage(i18n.tokensPerSecondUnit)}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCostSection && (
|
||||
<div className={section}>
|
||||
{hasValue(cost) && (
|
||||
<StatRow
|
||||
label={intl.formatMessage(i18n.cost)}
|
||||
value={
|
||||
<>
|
||||
{formatCost(cost)}
|
||||
{(costSource === 'provider_reported' || costSource === 'estimated') && (
|
||||
<span className="text-text-inverse/60">
|
||||
{' '}
|
||||
{intl.formatMessage(
|
||||
costSource === 'provider_reported' ? i18n.reported : i18n.estimated
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompaction && (
|
||||
<div className={cn(section, 'text-amber-300/90')}>
|
||||
{intl.formatMessage(i18n.compaction)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "श्रुतलेख के लिए कौन सा माइक्रोफ़ोन उपयोग करना है चुनें"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "音声入力に使用するマイクを選択"
|
||||
},
|
||||
|
||||
@@ -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": "받아쓰기에 사용할 마이크 선택"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Выберите микрофон для диктовки"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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ả"
|
||||
},
|
||||
|
||||
@@ -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": "选择用于语音输入的麦克风"
|
||||
},
|
||||
|
||||
@@ -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": "選擇要用於聽寫的麥克風"
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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<RecipeParameterDto>;
|
||||
|
||||
@@ -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: "<snake_case>"`).
|
||||
@@ -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)
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user