Skip to content

Commit 7b18ed4

Browse files
Merge pull request #64 from srijanAtGithub/dev
added usage trackers for agent and cowork
2 parents 06ffdb7 + 9046f07 commit 7b18ed4

6 files changed

Lines changed: 237 additions & 4 deletions

File tree

Cowork/cowork_session.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def _load_settings():
5757
log = structlog.get_logger()
5858

5959
BANNER = """
60-
╔═════════ Sicily Cowork v2.5.9 ════════════╦════════════════ What Sicily Can Do ════════════════╗
60+
╔═════════ Sicily Cowork v2.5.10 ═══════════╦════════════════ What Sicily Can Do ════════════════╗
6161
║ ║ ║
6262
║ ║ Sicily can search, inspect, read, organize, ║
6363
║ Files are sandboxed to this directory. ║ and safely modify the contents of your ║
@@ -286,6 +286,23 @@ def _on_index_progress(file_path: Path, current: int, total: int) -> None:
286286
if root_run_id is None:
287287
root_run_id = event.get("run_id")
288288

289+
# Track token usage from chat models securely in the background
290+
if event["event"] == "on_chat_model_end":
291+
output = event.get("data", {}).get("output")
292+
if output and hasattr(output, "usage_metadata") and output.usage_metadata:
293+
from usage_tracker import record_usage
294+
usage = output.usage_metadata
295+
# Try getting specific model from metadata, fallback to event name
296+
model_name = output.response_metadata.get("model_name", event.get("name", "unknown"))
297+
298+
record_usage(
299+
dimension="cowork",
300+
session_id=thread_id,
301+
model_name=model_name,
302+
input_tokens=usage.get("input_tokens", 0),
303+
output_tokens=usage.get("output_tokens", 0)
304+
)
305+
289306
# 3. Intercept tool execution
290307
if event["event"] == "on_tool_start":
291308
tool_call_count += 1

cli.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,59 @@ def uninstall():
324324
click.echo(f" pip error: {result.stderr.strip()}")
325325

326326

327+
@main_cli.command(name="usage")
328+
@click.option("--session", is_flag=True, help="Show usage for the last recorded session.")
329+
@click.option("--day", is_flag=True, help="Show usage for the last 24 hours.")
330+
@click.option("--week", is_flag=True, help="Show usage for the last 7 days (default).")
331+
def usage(session, day, week):
332+
"""Show token usage and estimated cost."""
333+
from usage_tracker import get_usage_report, init_db, cleanup_old_records
334+
from rich.console import Console
335+
from rich.table import Table
336+
337+
init_db()
338+
cleanup_old_records()
339+
340+
if session:
341+
timeframe = "session"
342+
title_suffix = "Last Session"
343+
elif day:
344+
timeframe = "day"
345+
title_suffix = "Last 24 Hours"
346+
else:
347+
timeframe = "week"
348+
title_suffix = "Last 7 Days"
349+
350+
report = get_usage_report(timeframe=timeframe)
351+
console = Console()
352+
353+
if not report:
354+
console.print(f"\n[yellow]No usage data found for: {title_suffix}[/yellow]\n")
355+
return
356+
357+
table = Table(title=f"Sicily Usage Report ({title_suffix})")
358+
table.add_column("Dimension", style="cyan")
359+
table.add_column("Model", style="magenta")
360+
table.add_column("Input Tokens", justify="right", style="green")
361+
table.add_column("Output Tokens", justify="right", style="green")
362+
table.add_column("Est. Cost (USD)", justify="right", style="bold yellow")
363+
364+
total_cost = 0.0
365+
for row in report:
366+
table.add_row(
367+
row["dimension"].capitalize(),
368+
row["model_name"],
369+
f"{row['in_tokens']:,}",
370+
f"{row['out_tokens']:,}",
371+
f"${row['total_cost']:.5f}"
372+
)
373+
total_cost += row['total_cost']
374+
375+
console.print()
376+
console.print(table)
377+
console.print(f"[bold right]Total Estimated Cost: ${total_cost:.5f}[/bold right]\n")
378+
379+
327380
@main_cli.command()
328381
def help():
329382
"""Show help."""
@@ -333,6 +386,7 @@ def help():
333386
click.echo(" config - Open the config folder")
334387
click.echo(" run - Run the agent")
335388
click.echo(" start - Start a local terminal session")
389+
click.echo(" usage - Show token usage and estimated cost")
336390
click.echo(" update - Update Sicily to the latest version")
337391
click.echo(" reset - Reset all config and indexes back to default")
338392
click.echo(" uninstall - Remove all local files and uninstall sicily")

main.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,29 @@ async def _run_send():
308308
try:
309309
result = await task
310310

311+
# Track token usage from the session state safely post-execution
312+
if not session.cancel_requested:
313+
try:
314+
current_graph = agent_module.graph
315+
if current_graph:
316+
state = await current_graph.aget_state({"configurable": {"thread_id": session_id}})
317+
for msg in state.values.get("messages", []):
318+
if hasattr(msg, "usage_metadata") and msg.usage_metadata:
319+
model_name = msg.response_metadata.get("model_name", "gpt-5.4-mini")
320+
msg_id = getattr(msg, "id", None)
321+
322+
from usage_tracker import record_usage
323+
record_usage(
324+
dimension="agent",
325+
session_id=session_id,
326+
model_name=model_name,
327+
input_tokens=msg.usage_metadata.get("input_tokens", 0),
328+
output_tokens=msg.usage_metadata.get("output_tokens", 0),
329+
message_id=msg_id
330+
)
331+
except Exception as token_err:
332+
log.warning("Failed to collect agent token metrics", error=str(token_err))
333+
311334
# Task completed normally — only reply if not cancelled.
312335
# (If cancel was requested mid-run, send() returns None result;
313336
# we just silently drop it per spec.)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "sicily"
7-
version = "2.5.9"
7+
version = "2.5.10"
88
description = "Add your description here"
99
readme = "README.md"
1010
requires-python = ">=3.12"

telegram_commands.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,50 @@ async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
6868
await update.message.reply_text("No active session.")
6969

7070

71+
async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
72+
from usage_tracker import get_usage_report, init_db, cleanup_old_records
73+
init_db()
74+
cleanup_old_records()
75+
76+
timeframe = "week"
77+
title_suffix = "Last 7 Days"
78+
79+
if context.args:
80+
arg = context.args[0].lower()
81+
if arg == "session":
82+
timeframe = "session"
83+
title_suffix = "Last Session"
84+
elif arg == "day":
85+
timeframe = "day"
86+
title_suffix = "Last 24 Hours"
87+
88+
report = get_usage_report(timeframe=timeframe)
89+
if not report:
90+
await update.message.reply_text(f" No usage metrics discovered for: {title_suffix}")
91+
return
92+
93+
text_lines = [f" *Sicily Usage Report ({title_suffix})*", ""]
94+
total_cost = 0.0
95+
96+
for row in report:
97+
dim = row["dimension"].upper()
98+
model = row["model_name"]
99+
in_t = row["in_tokens"]
100+
out_t = row["out_tokens"]
101+
cost = row["total_cost"]
102+
total_cost += cost
103+
104+
text_lines.append(
105+
f"▪️ *{dim}* — `{model}`\n"
106+
f" Input: {in_t:,}\n"
107+
f" Output: {out_t:,}\n"
108+
f" Cost: ${cost:.5f}\n"
109+
)
110+
111+
text_lines.append(f" *Total Estimated Cost: ${total_cost:.5f} USD*")
112+
await update.message.reply_text("\n".join(text_lines), parse_mode="Markdown")
113+
114+
71115
# DYNAMIC CONNECTOR COMMANDS
72116
#
73117
# Instead of one CommandHandler per connector (which forces every
@@ -163,20 +207,22 @@ def setup_command_handlers(telegram_app):
163207
telegram_app.add_handler(CommandHandler("start", start_command))
164208
telegram_app.add_handler(CommandHandler("stop", stop_command))
165209
telegram_app.add_handler(CommandHandler("status", status_command))
210+
telegram_app.add_handler(CommandHandler("usage", usage_command))
166211
telegram_app.add_handler(CommandHandler("connectors", connectors_command))
167212
telegram_app.add_handler(CommandHandler("loaded_connectors", loaded_connectors_command))
168213

169214
# Catch-all for /connect_* and /disconnect_* — must be added last so the
170-
# 5 static commands above get first refusal within the handler group.
215+
# static commands above get first refusal within the handler group.
171216
telegram_app.add_handler(MessageHandler(filters.COMMAND, connector_command_dispatch))
172217

173218

174219
async def setup_bot_commands(telegram_app):
175-
"""Sets the UI menu commands in Telegram — kept to the fixed 5."""
220+
"""Sets the UI menu commands in Telegram."""
176221
await telegram_app.bot.set_my_commands([
177222
BotCommand("start", "Start the bot"),
178223
BotCommand("stop", "Stop the current process"),
179224
BotCommand("status", "Show your session info"),
225+
BotCommand("usage", "Show token metrics and historical costs"),
180226
BotCommand("connectors", "Show available connectors to connect"),
181227
BotCommand("loaded_connectors", "Show connected connectors (and disconnect them)"),
182228
])

usage_tracker.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import sqlite3
2+
import time
3+
from pathlib import Path
4+
5+
SICILY_HOME = Path.home() / ".sicily"
6+
DB_PATH = SICILY_HOME / "Data" / "usage.db"
7+
8+
# Prices per 1M tokens
9+
MODEL_PRICING = {
10+
"gpt-4o-mini": {"input": 0.15 / 1_000_000, "output": 0.60 / 1_000_000},
11+
"gpt-5.4-mini": {"input": 0.75 / 1_000_000, "output": 4.50 / 1_000_000},
12+
"gpt-5.4-nano": {"input": 0.20 / 1_000_000, "output": 1.25 / 1_000_000},
13+
"gpt-4o-mini-transcribe": {"input": 1.25 / 1_000_000, "output": 5 / 1_000_000},
14+
}
15+
16+
17+
def get_cost(model_name: str, input_tokens: int, output_tokens: int) -> float:
18+
# Use gpt-4o-mini rates as a fallback if the specific model isn't mapped
19+
rates = MODEL_PRICING.get(model_name, MODEL_PRICING["gpt-5.4-mini"])
20+
return (input_tokens * rates["input"]) + (output_tokens * rates["output"])
21+
22+
23+
def init_db():
24+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
25+
with sqlite3.connect(DB_PATH) as conn:
26+
conn.execute("""
27+
CREATE TABLE IF NOT EXISTS token_usage (
28+
id INTEGER PRIMARY KEY AUTOINCREMENT,
29+
timestamp REAL,
30+
dimension TEXT,
31+
session_id TEXT,
32+
model_name TEXT,
33+
input_tokens INTEGER,
34+
output_tokens INTEGER,
35+
cost REAL,
36+
message_id TEXT UNIQUE
37+
)
38+
""")
39+
40+
41+
def record_usage(dimension: str, session_id: str, model_name: str, input_tokens: int, output_tokens: int, message_id: str = None):
42+
init_db()
43+
cost = get_cost(model_name, input_tokens, output_tokens)
44+
with sqlite3.connect(DB_PATH) as conn:
45+
conn.execute(
46+
"""
47+
INSERT OR IGNORE INTO token_usage
48+
(timestamp, dimension, session_id, model_name, input_tokens, output_tokens, cost, message_id)
49+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
50+
""",
51+
(time.time(), dimension, session_id, model_name, input_tokens, output_tokens, cost, message_id)
52+
)
53+
54+
55+
def cleanup_old_records():
56+
"""Keep only the last 30 days of usage."""
57+
cutoff = time.time() - (30 * 24 * 60 * 60)
58+
with sqlite3.connect(DB_PATH) as conn:
59+
conn.execute("DELETE FROM token_usage WHERE timestamp < ?", (cutoff,))
60+
61+
62+
def get_usage_report(timeframe="week") -> list[dict]:
63+
"""
64+
Returns aggregated usage data.
65+
timeframe can be: 'session', 'day', or 'week'.
66+
"""
67+
with sqlite3.connect(DB_PATH) as conn:
68+
conn.row_factory = sqlite3.Row
69+
cursor = conn.cursor()
70+
71+
if timeframe == "session":
72+
cursor.execute("SELECT session_id FROM token_usage ORDER BY timestamp DESC LIMIT 1")
73+
row = cursor.fetchone()
74+
if not row:
75+
return []
76+
77+
cursor.execute("""
78+
SELECT dimension, model_name, SUM(input_tokens) as in_tokens, SUM(output_tokens) as out_tokens, SUM(cost) as total_cost
79+
FROM token_usage
80+
WHERE session_id = ?
81+
GROUP BY dimension, model_name
82+
""", (row["session_id"],))
83+
else:
84+
days = 1 if timeframe == "day" else 7
85+
cutoff = time.time() - (days * 24 * 60 * 60)
86+
cursor.execute("""
87+
SELECT dimension, model_name, SUM(input_tokens) as in_tokens, SUM(output_tokens) as out_tokens, SUM(cost) as total_cost
88+
FROM token_usage
89+
WHERE timestamp >= ?
90+
GROUP BY dimension, model_name
91+
""", (cutoff,))
92+
93+
return [dict(row) for row in cursor.fetchall()]

0 commit comments

Comments
 (0)