Skip to content

Commit 2fde2da

Browse files
committed
display token usage and estimated cost after quit
1 parent 646db01 commit 2fde2da

6 files changed

Lines changed: 147 additions & 17 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ target
2222

2323
# Sofos workspace directory (contains sessions and personal instructions)
2424
.sofos/
25+
docs/

.sofosrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ This prints:
301301
- Model: claude-sonnet-4-5 (default)
302302
- Supports tool calling and server-side tools (web_search)
303303
- API version: 2023-06-01
304+
- Usage tracking: Automatic token counting and cost calculation
304305

305306
### Morph API
306307
- Uses Morph Apply REST API

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,29 @@ sofos
7373
- `think on` - Enable extended thinking
7474
- `think off` - Disable extended thinking
7575
- `think` - Show thinking status
76-
- `exit`, `quit`, or `Ctrl+D` - Exit
76+
- `exit`, `quit`, or `Ctrl+D` - Exit (displays session cost summary)
77+
78+
## Cost Tracking
79+
80+
Sofos automatically tracks token usage and calculates session costs. When you exit with `quit`, `exit`, or `Ctrl+D`, you'll see a summary:
81+
82+
```
83+
──────────────────────────────────────────────────
84+
Session Summary
85+
──────────────────────────────────────────────────
86+
Input tokens: 12,345
87+
Output tokens: 5,678
88+
Total tokens: 18,023
89+
90+
Estimated cost: $0.1304
91+
──────────────────────────────────────────────────
92+
```
93+
94+
**Cost Calculation:**
95+
- Costs are calculated based on official Claude pricing
96+
- Uses per-model pricing (Sonnet 4.5: $3/$15 per million input/output tokens)
97+
- All major Claude models are supported (Haiku, Sonnet, Opus variants)
98+
- Accurate for standard API usage
7799

78100
### Options
79101

src/api/client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ impl AnthropicClient {
6262
// Retry on network errors, timeouts, and some 5xx server errors
6363
error.is_timeout()
6464
|| error.is_connect()
65-
|| error.status().map_or(false, |s| s.is_server_error())
65+
|| error.status().is_some_and(|s| s.is_server_error())
6666
}
6767

6868
pub async fn create_message(

src/api/types.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,7 @@ pub struct CreateMessageResponse {
9090
pub _model: String,
9191
#[serde(rename = "stop_reason")]
9292
pub stop_reason: Option<String>,
93-
#[serde(rename = "usage")]
94-
pub _usage: Usage,
93+
pub usage: Usage,
9594
}
9695

9796
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -224,12 +223,10 @@ pub enum _ContentDelta {
224223
InputJsonDelta { partial_json: String },
225224
}
226225

227-
#[derive(Debug, Deserialize)]
226+
#[derive(Debug, Clone, Serialize, Deserialize)]
228227
pub struct Usage {
229-
#[serde(rename = "input_tokens")]
230-
pub _input_tokens: u32,
231-
#[serde(rename = "output_tokens")]
232-
pub _output_tokens: u32,
228+
pub input_tokens: u32,
229+
pub output_tokens: u32,
233230
}
234231

235232
// Streaming types

src/repl.rs

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ pub struct Repl {
2727
thinking_budget: u32,
2828
session_id: String,
2929
display_messages: Vec<crate::history::DisplayMessage>,
30+
// Session cost tracking
31+
total_input_tokens: u32,
32+
total_output_tokens: u32,
3033
}
3134

3235
impl Repl {
@@ -85,8 +88,101 @@ impl Repl {
8588
thinking_budget,
8689
session_id,
8790
display_messages: Vec::new(),
91+
total_input_tokens: 0,
92+
total_output_tokens: 0,
8893
})
8994
}
95+
96+
/// Calculate the cost for a given model based on token usage
97+
fn calculate_cost(model: &str, input_tokens: u32, output_tokens: u32) -> f64 {
98+
// Prices per million tokens in USD
99+
let (input_price, output_price) = match model {
100+
// Claude 4.5 models
101+
"claude-sonnet-4-5" | "claude-sonnet-4.5" => (3.0, 15.0),
102+
"claude-haiku-4-5" | "claude-haiku-4.5" => (0.8, 4.0),
103+
104+
// Claude 4 models
105+
"claude-sonnet-4" | "claude-4-sonnet-20250514" => (3.0, 15.0),
106+
"claude-opus-4" | "claude-4-opus-20250514" => (15.0, 75.0),
107+
108+
// Claude 4.1 models
109+
"claude-opus-4.1" | "claude-opus-4-1" => (6.0, 30.0),
110+
111+
// Claude 3.7 models
112+
"claude-sonnet-3-7" | "claude-sonnet-3.7" => (3.0, 15.0),
113+
114+
// Claude 3.5 models
115+
"claude-sonnet-3-5" | "claude-sonnet-3.5" => (3.0, 15.0),
116+
"claude-haiku-3-5" | "claude-haiku-3.5" => (0.8, 4.0),
117+
118+
// Claude 3 models (legacy)
119+
"claude-opus-3" | "claude-3-opus-20240229" => (15.0, 75.0),
120+
"claude-sonnet-3" | "claude-3-sonnet-20240229" => (3.0, 15.0),
121+
"claude-haiku-3" | "claude-3-haiku-20240307" => (0.25, 1.25),
122+
123+
// Default fallback (use Sonnet 4.5 pricing)
124+
_ => (3.0, 15.0),
125+
};
126+
127+
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price;
128+
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price;
129+
130+
input_cost + output_cost
131+
}
132+
133+
/// Format number with thousand separators
134+
fn format_number(n: u32) -> String {
135+
let s = n.to_string();
136+
let mut result = String::new();
137+
for (i, c) in s.chars().rev().enumerate() {
138+
if i > 0 && i % 3 == 0 {
139+
result.push(',');
140+
}
141+
result.push(c);
142+
}
143+
result.chars().rev().collect()
144+
}
145+
146+
/// Display session summary with cost information
147+
fn display_session_summary(&self) {
148+
if self.total_input_tokens == 0 && self.total_output_tokens == 0 {
149+
// No API calls made in this session
150+
return;
151+
}
152+
153+
println!();
154+
println!("{}", "─".repeat(50).bright_cyan());
155+
println!("{}", "Session Summary".bright_cyan().bold());
156+
println!("{}", "─".repeat(50).bright_cyan());
157+
158+
// Calculate estimated cost based on model pricing
159+
let estimated_cost = Self::calculate_cost(
160+
&self.model,
161+
self.total_input_tokens,
162+
self.total_output_tokens,
163+
);
164+
165+
println!("{:<20} {}",
166+
"Input tokens:".bright_white(),
167+
Self::format_number(self.total_input_tokens).bright_green()
168+
);
169+
println!("{:<20} {}",
170+
"Output tokens:".bright_white(),
171+
Self::format_number(self.total_output_tokens).bright_green()
172+
);
173+
println!("{:<20} {}",
174+
"Total tokens:".bright_white(),
175+
Self::format_number(self.total_input_tokens + self.total_output_tokens).bright_green()
176+
);
177+
println!();
178+
println!("{:<20} {}",
179+
"Estimated cost:".bright_white().bold(),
180+
format!("${:.4}", estimated_cost).bright_yellow().bold()
181+
);
182+
183+
println!("{}", "─".repeat(50).bright_cyan());
184+
println!();
185+
}
90186

91187
pub fn run(&mut self) -> Result<()> {
92188
println!("{}", "Sofos - AI Coding Assistant".bright_cyan().bold());
@@ -112,7 +208,9 @@ impl Repl {
112208
match line.to_lowercase().as_str() {
113209
"exit" | "quit" => {
114210
self.save_current_session()?;
115-
println!("\n{}", "Goodbye!".bright_cyan());
211+
self.display_session_summary();
212+
213+
println!("{}", "Goodbye!".bright_cyan());
116214
break;
117215
}
118216
"clear" => {
@@ -159,10 +257,8 @@ impl Repl {
159257

160258
if let Err(e) = self.process_message(line) {
161259
eprintln!("{} {}", "Error:".bright_red().bold(), e);
162-
} else {
163-
if let Err(e) = self.save_current_session() {
164-
eprintln!("{} Failed to save session: {}", "Warning:".bright_yellow(), e);
165-
}
260+
} else if let Err(e) = self.save_current_session() {
261+
eprintln!("{} Failed to save session: {}", "Warning:".bright_yellow(), e);
166262
}
167263

168264
println!();
@@ -172,6 +268,8 @@ impl Repl {
172268
}
173269
Err(ReadlineError::Eof) => {
174270
self.save_current_session()?;
271+
self.display_session_summary();
272+
175273
println!("{}", "Goodbye!".bright_cyan());
176274
break;
177275
}
@@ -267,6 +365,10 @@ impl Repl {
267365
}
268366

269367
let response = response?;
368+
369+
// Track token usage
370+
self.total_input_tokens += response.usage.input_tokens;
371+
self.total_output_tokens += response.usage.output_tokens;
270372

271373
self.handle_response(response.content, &runtime)?;
272374

@@ -311,7 +413,7 @@ impl Repl {
311413

312414
// Track this as a system message in display
313415
self.display_messages.push(crate::history::DisplayMessage::UserMessage {
314-
content: format!("[System: Maximum tool iterations reached]"),
416+
content: "[System: Maximum tool iterations reached]".to_string(),
315417
});
316418

317419
// Let Claude respond to the interruption
@@ -660,6 +762,11 @@ impl Repl {
660762
e
661763
);
662764
}
765+
766+
// Track token usage
767+
self.total_input_tokens += resp.usage.input_tokens;
768+
self.total_output_tokens += resp.usage.output_tokens;
769+
663770
resp
664771
}
665772
Err(e) => {
@@ -753,6 +860,8 @@ impl Repl {
753860
println!();
754861
self.process_message(prompt)?;
755862
self.save_current_session()?;
863+
self.display_session_summary();
864+
756865
Ok(())
757866
}
758867

@@ -843,7 +952,7 @@ impl Repl {
843952
crate::history::DisplayMessage::ToolExecution { tool_name, tool_input: _, tool_output } => {
844953
if tool_name == "execute_bash" {
845954
if let Ok(input_val) = serde_json::from_value::<serde_json::Value>(
846-
serde_json::to_value(&tool_output).unwrap_or_default()
955+
serde_json::to_value(tool_output).unwrap_or_default()
847956
) {
848957
if let Some(command) = input_val.get("command").and_then(|v| v.as_str()) {
849958
println!(
@@ -891,7 +1000,7 @@ impl Repl {
8911000

8921001
if line_count == 0 {
8931002
if file_path.is_empty() {
894-
format!("Read file (empty or not found)")
1003+
"Read file (empty or not found)".to_string()
8951004
} else {
8961005
format!("Read file from {} - empty or not found", file_path.bright_cyan())
8971006
}

0 commit comments

Comments
 (0)