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:
filip
2026-07-06 13:51:06 -07:00
committed by GitHub
parent 8694a8d104
commit af2c765c82
40 changed files with 2093 additions and 22 deletions
+1
View File
@@ -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
}
}
})
);
}
}
+10 -1
View File
@@ -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(
+121 -1
View File
@@ -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": {
+37
View File
@@ -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(
+18 -1
View File
@@ -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)
+66 -8
View File
@@ -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"
);
}
}
+110 -3
View File
@@ -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;
+1
View File
@@ -480,6 +480,7 @@ impl GatewayHandler {
}
}
Ok(AgentEvent::Usage(_)) => {}
Ok(AgentEvent::MessageUsage { .. }) => {}
Ok(AgentEvent::McpNotification(_)) => {
tracing::debug!(
session_id,
+1
View File
@@ -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
}
+23
View File
@@ -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);
}
+23 -4
View File
@@ -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>
);
}
+45
View File
@@ -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"
},
+45
View File
@@ -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"
},
+45
View File
@@ -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"
},
+45
View File
@@ -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"
},
+45
View File
@@ -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": "श्रुतलेख के लिए कौन सा माइक्रोफ़ोन उपयोग करना है चुनें"
},
+45
View File
@@ -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"
},
+45
View File
@@ -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"
},
+45
View File
@@ -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": "音声入力に使用するマイクを選択"
},
+45
View File
@@ -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": "받아쓰기에 사용할 마이크 선택"
},
+45
View File
@@ -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"
},
+45
View File
@@ -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"
},
+45
View File
@@ -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": "Выберите микрофон для диктовки"
},
+45
View File
@@ -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"
},
+45
View File
@@ -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ả"
},
+45
View File
@@ -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": "选择用于语音输入的麦克风"
},
+45
View File
@@ -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": "選擇要用於聽寫的麥克風"
},
+20
View File
@@ -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);
});
});
});
+55
View File
@@ -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
+40 -1
View File
@@ -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>;
+68 -1
View File
@@ -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)
]);
/**