Files
higress/.claude/skills/agent-session-monitor/scripts/cli.py

601 lines
22 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Agent Session Monitor CLI - 查询和分析agent对话数据
支持:
1. 实时查询指定session的完整llm请求和响应
2. 按模型统计token开销
3. 按日期统计token开销
4. 生成FinOps报表
"""
import argparse
import json
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional
import re
# Token定价单位美元/1M tokens
TOKEN_PRICING = {
"Qwen": {
"input": 0.0002, # $0.2/1M
"output": 0.0006,
"cached": 0.0001, # cached tokens通常是input的50%
},
"Qwen3-rerank": {
"input": 0.0003,
"output": 0.0012,
"cached": 0.00015,
},
"Qwen-Max": {
"input": 0.0005,
"output": 0.002,
"cached": 0.00025,
},
"GPT-4": {
"input": 0.003,
"output": 0.006,
"cached": 0.0015,
},
"GPT-4o": {
"input": 0.0025,
"output": 0.01,
"cached": 0.00125, # GPT-4o prompt caching: 50% discount
},
"GPT-4-32k": {
"input": 0.01,
"output": 0.03,
"cached": 0.005,
},
"o1": {
"input": 0.015,
"output": 0.06,
"cached": 0.0075,
"reasoning": 0.06, # o1 reasoning tokens same as output
},
"o1-mini": {
"input": 0.003,
"output": 0.012,
"cached": 0.0015,
"reasoning": 0.012,
},
"Claude": {
"input": 0.015,
"output": 0.075,
"cached": 0.0015, # Claude prompt caching: 90% discount
},
"DeepSeek-R1": {
"input": 0.004,
"output": 0.012,
"reasoning": 0.002,
"cached": 0.002,
}
}
class SessionAnalyzer:
"""Session数据分析器"""
def __init__(self, data_dir: str):
self.data_dir = Path(data_dir)
if not self.data_dir.exists():
raise FileNotFoundError(f"Session data directory not found: {data_dir}")
def load_session(self, session_id: str) -> Optional[dict]:
"""加载指定session的完整数据"""
session_file = self.data_dir / f"{session_id}.json"
if not session_file.exists():
return None
with open(session_file, 'r', encoding='utf-8') as f:
return json.load(f)
def load_all_sessions(self) -> List[dict]:
"""加载所有session数据"""
sessions = []
for session_file in self.data_dir.glob("*.json"):
try:
with open(session_file, 'r', encoding='utf-8') as f:
session = json.load(f)
sessions.append(session)
except Exception as e:
print(f"Warning: Failed to load {session_file}: {e}", file=sys.stderr)
return sessions
def display_session_detail(self, session_id: str, show_messages: bool = True):
"""显示session的详细信息"""
session = self.load_session(session_id)
if not session:
print(f"❌ Session not found: {session_id}")
return
print(f"\n{'='*70}")
print(f"📊 Session Detail: {session_id}")
print(f"{'='*70}\n")
# 基本信息
print(f"🕐 Created: {session['created_at']}")
print(f"🕑 Updated: {session['updated_at']}")
print(f"🤖 Model: {session['model']}")
print(f"💬 Messages: {session['messages_count']}")
print()
# Token统计
print(f"📈 Token Statistics:")
total_input = session['total_input_tokens']
total_output = session['total_output_tokens']
total_reasoning = session.get('total_reasoning_tokens', 0)
total_cached = session.get('total_cached_tokens', 0)
# 区分regular input和cached input
regular_input = total_input - total_cached
if total_cached > 0:
print(f" Input: {regular_input:>10,} tokens (regular)")
print(f" Cached: {total_cached:>10,} tokens (from cache)")
print(f" Total Input:{total_input:>10,} tokens")
else:
print(f" Input: {total_input:>10,} tokens")
print(f" Output: {total_output:>10,} tokens")
if total_reasoning > 0:
print(f" Reasoning: {total_reasoning:>10,} tokens")
# 总计不重复计算cached
total_tokens = total_input + total_output + total_reasoning
print(f" ────────────────────────")
print(f" Total: {total_tokens:>10,} tokens")
print()
# 成本计算
cost = self._calculate_cost(session)
print(f"💰 Estimated Cost: ${cost:.8f} USD")
print()
# 对话轮次
if show_messages and 'rounds' in session:
print(f"📝 Conversation Rounds ({len(session['rounds'])}):")
print(f"{''*70}")
for i, round_data in enumerate(session['rounds'], 1):
timestamp = round_data.get('timestamp', 'N/A')
input_tokens = round_data.get('input_tokens', 0)
output_tokens = round_data.get('output_tokens', 0)
has_tool_calls = round_data.get('has_tool_calls', False)
response_type = round_data.get('response_type', 'normal')
print(f"\n Round {i} @ {timestamp}")
print(f" Tokens: {input_tokens:,} in → {output_tokens:,} out")
if has_tool_calls:
print(f" 🔧 Tool calls: Yes")
if response_type != 'normal':
print(f" Type: {response_type}")
# 显示完整的messages如果有
if 'messages' in round_data:
messages = round_data['messages']
print(f" Messages ({len(messages)}):")
for msg in messages[-3:]: # 只显示最后3条
role = msg.get('role', 'unknown')
content = msg.get('content', '')
content_preview = content[:100] + '...' if len(content) > 100 else content
print(f" [{role}] {content_preview}")
# 显示question/answer/reasoning如果有
if 'question' in round_data:
q = round_data['question']
q_preview = q[:150] + '...' if len(q) > 150 else q
print(f" ❓ Question: {q_preview}")
if 'answer' in round_data:
a = round_data['answer']
a_preview = a[:150] + '...' if len(a) > 150 else a
print(f" ✅ Answer: {a_preview}")
if 'reasoning' in round_data and round_data['reasoning']:
r = round_data['reasoning']
r_preview = r[:150] + '...' if len(r) > 150 else r
print(f" 🧠 Reasoning: {r_preview}")
if 'tool_calls' in round_data and round_data['tool_calls']:
print(f" 🛠️ Tool Calls:")
for tool_call in round_data['tool_calls']:
func_name = tool_call.get('function', {}).get('name', 'unknown')
args = tool_call.get('function', {}).get('arguments', '')
print(f" - {func_name}({args[:80]}...)")
# 显示token details如果有
if round_data.get('input_token_details'):
print(f" 📊 Input Token Details: {round_data['input_token_details']}")
if round_data.get('output_token_details'):
print(f" 📊 Output Token Details: {round_data['output_token_details']}")
print(f"\n{''*70}")
print(f"\n{'='*70}\n")
def _calculate_cost(self, session: dict) -> float:
"""计算session的成本"""
model = session.get('model', 'unknown')
pricing = TOKEN_PRICING.get(model, TOKEN_PRICING.get("GPT-4", {}))
input_tokens = session['total_input_tokens']
output_tokens = session['total_output_tokens']
reasoning_tokens = session.get('total_reasoning_tokens', 0)
cached_tokens = session.get('total_cached_tokens', 0)
# 区分regular input和cached input
regular_input_tokens = input_tokens - cached_tokens
input_cost = regular_input_tokens * pricing.get('input', 0) / 1000000
output_cost = output_tokens * pricing.get('output', 0) / 1000000
reasoning_cost = 0
if 'reasoning' in pricing and reasoning_tokens > 0:
reasoning_cost = reasoning_tokens * pricing['reasoning'] / 1000000
cached_cost = 0
if 'cached' in pricing and cached_tokens > 0:
cached_cost = cached_tokens * pricing['cached'] / 1000000
return input_cost + output_cost + reasoning_cost + cached_cost
def stats_by_model(self) -> Dict[str, dict]:
"""按模型统计token开销"""
sessions = self.load_all_sessions()
stats = defaultdict(lambda: {
'session_count': 0,
'total_input': 0,
'total_output': 0,
'total_reasoning': 0,
'total_cost': 0.0
})
for session in sessions:
model = session.get('model', 'unknown')
stats[model]['session_count'] += 1
stats[model]['total_input'] += session['total_input_tokens']
stats[model]['total_output'] += session['total_output_tokens']
stats[model]['total_reasoning'] += session.get('total_reasoning_tokens', 0)
stats[model]['total_cost'] += self._calculate_cost(session)
return dict(stats)
def stats_by_date(self, days: int = 30) -> Dict[str, dict]:
"""按日期统计token开销最近N天"""
sessions = self.load_all_sessions()
stats = defaultdict(lambda: {
'session_count': 0,
'total_input': 0,
'total_output': 0,
'total_reasoning': 0,
'total_cost': 0.0,
'models': set()
})
cutoff_date = datetime.now() - timedelta(days=days)
for session in sessions:
created_at = datetime.fromisoformat(session['created_at'])
if created_at < cutoff_date:
continue
date_key = created_at.strftime('%Y-%m-%d')
stats[date_key]['session_count'] += 1
stats[date_key]['total_input'] += session['total_input_tokens']
stats[date_key]['total_output'] += session['total_output_tokens']
stats[date_key]['total_reasoning'] += session.get('total_reasoning_tokens', 0)
stats[date_key]['total_cost'] += self._calculate_cost(session)
stats[date_key]['models'].add(session.get('model', 'unknown'))
# 转换sets为lists以便JSON序列化
for date_key in stats:
stats[date_key]['models'] = list(stats[date_key]['models'])
return dict(stats)
def display_model_stats(self):
"""显示按模型的统计"""
stats = self.stats_by_model()
print(f"\n{'='*80}")
print(f"📊 Statistics by Model")
print(f"{'='*80}\n")
print(f"{'Model':<20} {'Sessions':<10} {'Input':<15} {'Output':<15} {'Cost (USD)':<12}")
print(f"{''*80}")
# 按成本降序排列
sorted_models = sorted(stats.items(), key=lambda x: x[1]['total_cost'], reverse=True)
for model, data in sorted_models:
print(f"{model:<20} "
f"{data['session_count']:<10} "
f"{data['total_input']:>12,} "
f"{data['total_output']:>12,} "
f"${data['total_cost']:>10.6f}")
# 总计
total_sessions = sum(d['session_count'] for d in stats.values())
total_input = sum(d['total_input'] for d in stats.values())
total_output = sum(d['total_output'] for d in stats.values())
total_cost = sum(d['total_cost'] for d in stats.values())
print(f"{''*80}")
print(f"{'TOTAL':<20} "
f"{total_sessions:<10} "
f"{total_input:>12,} "
f"{total_output:>12,} "
f"${total_cost:>10.6f}")
print(f"\n{'='*80}\n")
def display_date_stats(self, days: int = 30):
"""显示按日期的统计"""
stats = self.stats_by_date(days)
print(f"\n{'='*80}")
print(f"📊 Statistics by Date (Last {days} days)")
print(f"{'='*80}\n")
print(f"{'Date':<12} {'Sessions':<10} {'Input':<15} {'Output':<15} {'Cost (USD)':<12} {'Models':<20}")
print(f"{''*80}")
# 按日期升序排列
sorted_dates = sorted(stats.items())
for date, data in sorted_dates:
models_str = ', '.join(data['models'][:3]) # 最多显示3个模型
if len(data['models']) > 3:
models_str += f" +{len(data['models'])-3}"
print(f"{date:<12} "
f"{data['session_count']:<10} "
f"{data['total_input']:>12,} "
f"{data['total_output']:>12,} "
f"${data['total_cost']:>10.4f} "
f"{models_str}")
# 总计
total_sessions = sum(d['session_count'] for d in stats.values())
total_input = sum(d['total_input'] for d in stats.values())
total_output = sum(d['total_output'] for d in stats.values())
total_cost = sum(d['total_cost'] for d in stats.values())
print(f"{''*80}")
print(f"{'TOTAL':<12} "
f"{total_sessions:<10} "
f"{total_input:>12,} "
f"{total_output:>12,} "
f"${total_cost:>10.4f}")
print(f"\n{'='*80}\n")
def list_sessions(self, limit: int = 20, sort_by: str = 'updated'):
"""列出所有session"""
sessions = self.load_all_sessions()
# 排序
if sort_by == 'updated':
sessions.sort(key=lambda s: s.get('updated_at', ''), reverse=True)
elif sort_by == 'cost':
sessions.sort(key=lambda s: self._calculate_cost(s), reverse=True)
elif sort_by == 'tokens':
sessions.sort(key=lambda s: s['total_input_tokens'] + s['total_output_tokens'], reverse=True)
print(f"\n{'='*100}")
print(f"📋 Sessions (sorted by {sort_by}, showing {min(limit, len(sessions))} of {len(sessions)})")
print(f"{'='*100}\n")
print(f"{'Session ID':<30} {'Updated':<20} {'Model':<15} {'Msgs':<6} {'Tokens':<12} {'Cost':<10}")
print(f"{''*100}")
for session in sessions[:limit]:
session_id = session['session_id'][:28] + '..' if len(session['session_id']) > 30 else session['session_id']
updated = session.get('updated_at', 'N/A')[:19]
model = session.get('model', 'unknown')[:13]
msg_count = session.get('messages_count', 0)
total_tokens = session['total_input_tokens'] + session['total_output_tokens']
cost = self._calculate_cost(session)
print(f"{session_id:<30} {updated:<20} {model:<15} {msg_count:<6} {total_tokens:>10,} ${cost:>8.4f}")
print(f"\n{'='*100}\n")
def export_finops_report(self, output_file: str, format: str = 'json'):
"""导出FinOps报表"""
model_stats = self.stats_by_model()
date_stats = self.stats_by_date(30)
report = {
'generated_at': datetime.now().isoformat(),
'summary': {
'total_sessions': sum(d['session_count'] for d in model_stats.values()),
'total_input_tokens': sum(d['total_input'] for d in model_stats.values()),
'total_output_tokens': sum(d['total_output'] for d in model_stats.values()),
'total_cost_usd': sum(d['total_cost'] for d in model_stats.values()),
},
'by_model': model_stats,
'by_date': date_stats,
}
output_path = Path(output_file)
if format == 'json':
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"✅ FinOps report exported to: {output_path}")
elif format == 'csv':
import csv
# 按模型导出CSV
model_csv = output_path.with_suffix('.model.csv')
with open(model_csv, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['Model', 'Sessions', 'Input Tokens', 'Output Tokens', 'Cost (USD)'])
for model, data in model_stats.items():
writer.writerow([
model,
data['session_count'],
data['total_input'],
data['total_output'],
f"{data['total_cost']:.6f}"
])
# 按日期导出CSV
date_csv = output_path.with_suffix('.date.csv')
with open(date_csv, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['Date', 'Sessions', 'Input Tokens', 'Output Tokens', 'Cost (USD)', 'Models'])
for date, data in sorted(date_stats.items()):
writer.writerow([
date,
data['session_count'],
data['total_input'],
data['total_output'],
f"{data['total_cost']:.6f}",
', '.join(data['models'])
])
print(f"✅ FinOps report exported to:")
print(f" Model stats: {model_csv}")
print(f" Date stats: {date_csv}")
def main():
parser = argparse.ArgumentParser(
description="Agent Session Monitor CLI - 查询和分析agent对话数据",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Commands:
show <session-id> 显示session的详细信息
list 列出所有session
stats-model 按模型统计token开销
stats-date 按日期统计token开销默认30天
export 导出FinOps报表
Examples:
# 查看特定session的详细对话
%(prog)s show agent:main:discord:channel:1465367993012981988
# 列出最近20个session按更新时间
%(prog)s list
# 列出token开销最高的10个session
%(prog)s list --sort-by cost --limit 10
# 按模型统计token开销
%(prog)s stats-model
# 按日期统计token开销最近7天
%(prog)s stats-date --days 7
# 导出FinOps报表JSON格式
%(prog)s export finops-report.json
# 导出FinOps报表CSV格式
%(prog)s export finops-report --format csv
"""
)
parser.add_argument(
'command',
choices=['show', 'list', 'stats-model', 'stats-date', 'export'],
help='命令'
)
parser.add_argument(
'args',
nargs='*',
help='命令参数例如session-id或输出文件名'
)
parser.add_argument(
'--data-dir',
default='./sessions',
help='Session数据目录默认: ./sessions'
)
parser.add_argument(
'--limit',
type=int,
default=20,
help='list命令的结果限制默认: 20'
)
parser.add_argument(
'--sort-by',
choices=['updated', 'cost', 'tokens'],
default='updated',
help='list命令的排序方式默认: updated'
)
parser.add_argument(
'--days',
type=int,
default=30,
help='stats-date命令的天数默认: 30'
)
parser.add_argument(
'--format',
choices=['json', 'csv'],
default='json',
help='export命令的输出格式默认: json'
)
parser.add_argument(
'--no-messages',
action='store_true',
help='show命令不显示对话内容'
)
args = parser.parse_args()
try:
analyzer = SessionAnalyzer(args.data_dir)
if args.command == 'show':
if not args.args:
parser.error("show命令需要session-id参数")
session_id = args.args[0]
analyzer.display_session_detail(session_id, show_messages=not args.no_messages)
elif args.command == 'list':
analyzer.list_sessions(limit=args.limit, sort_by=args.sort_by)
elif args.command == 'stats-model':
analyzer.display_model_stats()
elif args.command == 'stats-date':
analyzer.display_date_stats(days=args.days)
elif args.command == 'export':
if not args.args:
parser.error("export命令需要输出文件名参数")
output_file = args.args[0]
analyzer.export_finops_report(output_file, format=args.format)
except FileNotFoundError as e:
print(f"❌ Error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"❌ Unexpected error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == '__main__':
main()