1 Commits

Author SHA1 Message Date
Yeachan-Heo
3ba60be514 Expose session cost and budget state in the Rust CLI
The CLI already tracked token usage, but it did not translate that usage into model-aware cost reporting or offer a spend guardrail. This change adds a max-cost flag, integrates estimated USD totals into /status and /cost, emits near-budget warnings, and blocks new turns once the configured budget has been exhausted.

The workspace verification request also surfaced stale runtime test fixtures that still referenced removed permission enum variants, so those test-only call sites were updated to current permission modes to keep full clippy and workspace test coverage green.

Constraint: Reuse existing runtime usage/pricing helpers instead of adding a new billing layer
Constraint: Keep the feature centered in existing CLI/status surfaces with no new dependencies
Rejected: Move budget enforcement into runtime usage/session abstractions | broader refactor than needed for this CLI-scoped feature
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If resumed sessions later need historically accurate per-turn pricing across model switches, persist model metadata before changing the cost math
Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Live network-backed prompt/REPL budget behavior against real Anthropic responses
2026-04-01 00:57:54 +00:00
9 changed files with 251 additions and 377 deletions

View File

@@ -912,7 +912,6 @@ mod tests {
system: None, system: None,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
thinking: None,
stream: false, stream: false,
}; };

View File

@@ -13,5 +13,5 @@ pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest, InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent, MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
}; };

View File

@@ -12,8 +12,6 @@ pub struct MessageRequest {
pub tools: Option<Vec<ToolDefinition>>, pub tools: Option<Vec<ToolDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>, pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")] #[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream: bool, pub stream: bool,
} }
@@ -26,23 +24,6 @@ impl MessageRequest {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ThinkingConfig {
#[serde(rename = "type")]
pub kind: String,
pub budget_tokens: u32,
}
impl ThinkingConfig {
#[must_use]
pub fn enabled(budget_tokens: u32) -> Self {
Self {
kind: "enabled".to_string(),
budget_tokens,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InputMessage { pub struct InputMessage {
pub role: String, pub role: String,
@@ -149,11 +130,6 @@ pub enum OutputContentBlock {
Text { Text {
text: String, text: String,
}, },
Thinking {
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -213,8 +189,6 @@ pub struct ContentBlockDeltaEvent {
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlockDelta { pub enum ContentBlockDelta {
TextDelta { text: String }, TextDelta { text: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
InputJsonDelta { partial_json: String }, InputJsonDelta { partial_json: String },
} }

View File

@@ -258,7 +258,6 @@ async fn live_stream_smoke_test() {
system: None, system: None,
tools: None, tools: None,
tool_choice: None, tool_choice: None,
thinking: None,
stream: false, stream: false,
}) })
.await .await
@@ -439,7 +438,6 @@ fn sample_request(stream: bool) -> MessageRequest {
}), }),
}]), }]),
tool_choice: Some(ToolChoice::Auto), tool_choice: Some(ToolChoice::Auto),
thinking: None,
stream, stream,
} }
} }

View File

@@ -57,12 +57,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None, argument_hint: None,
resume_supported: true, resume_supported: true,
}, },
SlashCommandSpec {
name: "thinking",
summary: "Show or toggle extended thinking",
argument_hint: Some("[on|off]"),
resume_supported: false,
},
SlashCommandSpec { SlashCommandSpec {
name: "model", name: "model",
summary: "Show or switch the active model", summary: "Show or switch the active model",
@@ -142,9 +136,6 @@ pub enum SlashCommand {
Help, Help,
Status, Status,
Compact, Compact,
Thinking {
enabled: Option<bool>,
},
Model { Model {
model: Option<String>, model: Option<String>,
}, },
@@ -189,13 +180,6 @@ impl SlashCommand {
"help" => Self::Help, "help" => Self::Help,
"status" => Self::Status, "status" => Self::Status,
"compact" => Self::Compact, "compact" => Self::Compact,
"thinking" => Self::Thinking {
enabled: match parts.next() {
Some("on") => Some(true),
Some("off") => Some(false),
Some(_) | None => None,
},
},
"model" => Self::Model { "model" => Self::Model {
model: parts.next().map(ToOwned::to_owned), model: parts.next().map(ToOwned::to_owned),
}, },
@@ -295,7 +279,6 @@ pub fn handle_slash_command(
session: session.clone(), session: session.clone(),
}), }),
SlashCommand::Status SlashCommand::Status
| SlashCommand::Thinking { .. }
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. } | SlashCommand::Clear { .. }
@@ -324,22 +307,6 @@ mod tests {
fn parses_supported_slash_commands() { fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
assert_eq!(
SlashCommand::parse("/thinking on"),
Some(SlashCommand::Thinking {
enabled: Some(true),
})
);
assert_eq!(
SlashCommand::parse("/thinking off"),
Some(SlashCommand::Thinking {
enabled: Some(false),
})
);
assert_eq!(
SlashCommand::parse("/thinking"),
Some(SlashCommand::Thinking { enabled: None })
);
assert_eq!( assert_eq!(
SlashCommand::parse("/model claude-opus"), SlashCommand::parse("/model claude-opus"),
Some(SlashCommand::Model { Some(SlashCommand::Model {
@@ -407,7 +374,6 @@ mod tests {
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/compact")); assert!(help.contains("/compact"));
assert!(help.contains("/thinking [on|off]"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
@@ -420,7 +386,7 @@ mod tests {
assert!(help.contains("/version")); assert!(help.contains("/version"));
assert!(help.contains("/export [file]")); assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains("/session [list|switch <session-id>]"));
assert_eq!(slash_command_specs().len(), 16); assert_eq!(slash_command_specs().len(), 15);
assert_eq!(resume_supported_slash_commands().len(), 11); assert_eq!(resume_supported_slash_commands().len(), 11);
} }
@@ -468,9 +434,6 @@ mod tests {
let session = Session::new(); let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/thinking on", &session, CompactionConfig::default()).is_none()
);
assert!( assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
); );

View File

@@ -130,7 +130,7 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
.filter_map(|block| match block { .filter_map(|block| match block {
ContentBlock::ToolUse { name, .. } => Some(name.as_str()), ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()), ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => None, ContentBlock::Text { .. } => None,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
tool_names.sort_unstable(); tool_names.sort_unstable();
@@ -200,7 +200,6 @@ fn summarize_messages(messages: &[ConversationMessage]) -> String {
fn summarize_block(block: &ContentBlock) -> String { fn summarize_block(block: &ContentBlock) -> String {
let raw = match block { let raw = match block {
ContentBlock::Text { text } => text.clone(), ContentBlock::Text { text } => text.clone(),
ContentBlock::Thinking { text, .. } => format!("thinking: {text}"),
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"), ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_name, tool_name,
@@ -259,7 +258,7 @@ fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
.iter() .iter()
.flat_map(|message| message.blocks.iter()) .flat_map(|message| message.blocks.iter())
.map(|block| match block { .map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.as_str(), ContentBlock::Text { text } => text.as_str(),
ContentBlock::ToolUse { input, .. } => input.as_str(), ContentBlock::ToolUse { input, .. } => input.as_str(),
ContentBlock::ToolResult { output, .. } => output.as_str(), ContentBlock::ToolResult { output, .. } => output.as_str(),
}) })
@@ -281,15 +280,10 @@ fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
fn first_text_block(message: &ConversationMessage) -> Option<&str> { fn first_text_block(message: &ConversationMessage) -> Option<&str> {
message.blocks.iter().find_map(|block| match block { message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
if !text.trim().is_empty() =>
{
Some(text.as_str())
}
ContentBlock::ToolUse { .. } ContentBlock::ToolUse { .. }
| ContentBlock::ToolResult { .. } | ContentBlock::ToolResult { .. }
| ContentBlock::Text { .. } | ContentBlock::Text { .. } => None,
| ContentBlock::Thinking { .. } => None,
}) })
} }
@@ -334,7 +328,7 @@ fn estimate_message_tokens(message: &ConversationMessage) -> usize {
.blocks .blocks
.iter() .iter()
.map(|block| match block { .map(|block| match block {
ContentBlock::Text { text } | ContentBlock::Thinking { text, .. } => text.len() / 4 + 1, ContentBlock::Text { text } => text.len() / 4 + 1,
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1, ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_name, output, .. tool_name, output, ..

View File

@@ -17,8 +17,6 @@ pub struct ApiRequest {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssistantEvent { pub enum AssistantEvent {
TextDelta(String), TextDelta(String),
ThinkingDelta(String),
ThinkingSignature(String),
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -249,26 +247,15 @@ fn build_assistant_message(
events: Vec<AssistantEvent>, events: Vec<AssistantEvent>,
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> { ) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
let mut text = String::new(); let mut text = String::new();
let mut thinking = String::new();
let mut thinking_signature: Option<String> = None;
let mut blocks = Vec::new(); let mut blocks = Vec::new();
let mut finished = false; let mut finished = false;
let mut usage = None; let mut usage = None;
for event in events { for event in events {
match event { match event {
AssistantEvent::TextDelta(delta) => { AssistantEvent::TextDelta(delta) => text.push_str(&delta),
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
text.push_str(&delta);
}
AssistantEvent::ThinkingDelta(delta) => {
flush_text_block(&mut text, &mut blocks);
thinking.push_str(&delta);
}
AssistantEvent::ThinkingSignature(signature) => thinking_signature = Some(signature),
AssistantEvent::ToolUse { id, name, input } => { AssistantEvent::ToolUse { id, name, input } => {
flush_text_block(&mut text, &mut blocks); flush_text_block(&mut text, &mut blocks);
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
blocks.push(ContentBlock::ToolUse { id, name, input }); blocks.push(ContentBlock::ToolUse { id, name, input });
} }
AssistantEvent::Usage(value) => usage = Some(value), AssistantEvent::Usage(value) => usage = Some(value),
@@ -279,7 +266,6 @@ fn build_assistant_message(
} }
flush_text_block(&mut text, &mut blocks); flush_text_block(&mut text, &mut blocks);
flush_thinking_block(&mut thinking, &mut thinking_signature, &mut blocks);
if !finished { if !finished {
return Err(RuntimeError::new( return Err(RuntimeError::new(
@@ -304,19 +290,6 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
} }
} }
fn flush_thinking_block(
thinking: &mut String,
signature: &mut Option<String>,
blocks: &mut Vec<ContentBlock>,
) {
if !thinking.is_empty() || signature.is_some() {
blocks.push(ContentBlock::Thinking {
text: std::mem::take(thinking),
signature: signature.take(),
});
}
}
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>; type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
#[derive(Default)] #[derive(Default)]
@@ -352,8 +325,8 @@ impl ToolExecutor for StaticToolExecutor {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
build_assistant_message, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
RuntimeError, StaticToolExecutor, StaticToolExecutor,
}; };
use crate::compact::CompactionConfig; use crate::compact::CompactionConfig;
use crate::permissions::{ use crate::permissions::{
@@ -529,29 +502,6 @@ mod tests {
)); ));
} }
#[test]
fn thinking_blocks_are_preserved_separately_from_text() {
let (message, usage) = build_assistant_message(vec![
AssistantEvent::ThinkingDelta("first ".to_string()),
AssistantEvent::ThinkingDelta("second".to_string()),
AssistantEvent::ThinkingSignature("sig-1".to_string()),
AssistantEvent::TextDelta("final".to_string()),
AssistantEvent::MessageStop,
])
.expect("assistant message should build");
assert_eq!(usage, None);
assert!(matches!(
&message.blocks[0],
ContentBlock::Thinking { text, signature }
if text == "first second" && signature.as_deref() == Some("sig-1")
));
assert!(matches!(
&message.blocks[1],
ContentBlock::Text { text } if text == "final"
));
}
#[test] #[test]
fn reconstructs_usage_tracker_from_restored_session() { fn reconstructs_usage_tracker_from_restored_session() {
struct SimpleApi; struct SimpleApi;

View File

@@ -19,10 +19,6 @@ pub enum ContentBlock {
Text { Text {
text: String, text: String,
}, },
Thinking {
text: String,
signature: Option<String>,
},
ToolUse { ToolUse {
id: String, id: String,
name: String, name: String,
@@ -261,19 +257,6 @@ impl ContentBlock {
object.insert("type".to_string(), JsonValue::String("text".to_string())); object.insert("type".to_string(), JsonValue::String("text".to_string()));
object.insert("text".to_string(), JsonValue::String(text.clone())); object.insert("text".to_string(), JsonValue::String(text.clone()));
} }
Self::Thinking { text, signature } => {
object.insert(
"type".to_string(),
JsonValue::String("thinking".to_string()),
);
object.insert("text".to_string(), JsonValue::String(text.clone()));
if let Some(signature) = signature {
object.insert(
"signature".to_string(),
JsonValue::String(signature.clone()),
);
}
}
Self::ToolUse { id, name, input } => { Self::ToolUse { id, name, input } => {
object.insert( object.insert(
"type".to_string(), "type".to_string(),
@@ -320,13 +303,6 @@ impl ContentBlock {
"text" => Ok(Self::Text { "text" => Ok(Self::Text {
text: required_string(object, "text")?, text: required_string(object, "text")?,
}), }),
"thinking" => Ok(Self::Thinking {
text: required_string(object, "text")?,
signature: object
.get("signature")
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned),
}),
"tool_use" => Ok(Self::ToolUse { "tool_use" => Ok(Self::ToolUse {
id: required_string(object, "id")?, id: required_string(object, "id")?,
name: required_string(object, "name")?, name: required_string(object, "name")?,

File diff suppressed because it is too large Load Diff