Coverage for agentos/observability/cost_analytics.py: 31%

236 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 10:59 +0800

1""" 

2AgentOS v0.70 — 成本分析与运营仪表板。 

3基因来源: OpenAI Usage Dashboard + Grafana 

4 

5提供: 

6- 按模型/按天/按session的多维度成本统计 

7- Token消耗趋势分析 

8- 预算预警系统 

9- 成本预测(简单滑动平均) 

10""" 

11 

12from __future__ import annotations 

13 

14import json 

15import os 

16import time 

17import threading 

18from collections import defaultdict 

19from dataclasses import dataclass 

20 

21from agentos.cost.tracker import CostTracker, PRICING 

22 

23 

24@dataclass 

25class CostEntry: 

26 """单次调用的成本记录。""" 

27 timestamp: float 

28 model: str 

29 session_id: str 

30 input_tokens: int 

31 output_tokens: int 

32 cost_usd: float 

33 duration_ms: float = 0.0 

34 

35 

36@dataclass 

37class DailySummary: 

38 """日成本摘要。""" 

39 date: str 

40 model: str 

41 calls: int = 0 

42 total_input_tokens: int = 0 

43 total_output_tokens: int = 0 

44 total_cost_usd: float = 0.0 

45 avg_duration_ms: float = 0.0 

46 

47 

48@dataclass 

49class CostBreakdown: 

50 """单次调用的详细成本分解。""" 

51 model: str 

52 input_tokens: int 

53 output_tokens: int 

54 input_cost_usd: float 

55 output_cost_usd: float 

56 total_cost_usd: float 

57 token_cost_ratio: str = "" # e.g. "1:2.5" 

58 

59 def __post_init__(self): 

60 if self.output_cost_usd > 0: 

61 r = self.input_cost_usd / self.output_cost_usd 

62 self.token_cost_ratio = f"1:{r:.1f}" if r > 1 else f"{1/r:.1f}:1" 

63 

64 

65@dataclass 

66class CostSession: 

67 """单次会话的成本摘要。""" 

68 session_id: str 

69 model: str = "" 

70 calls: int = 0 

71 total_input_tokens: int = 0 

72 total_output_tokens: int = 0 

73 total_cost_usd: float = 0.0 

74 start_time: float = 0.0 

75 end_time: float = 0.0 

76 status: str = "active" 

77 

78 

79@dataclass 

80class BudgetAlert: 

81 """预算告警。""" 

82 triggered: bool 

83 current_cost: float 

84 budget: float 

85 pct_used: float 

86 projected_daily: float 

87 message: str 

88 

89 

90class CostAnalytics: 

91 """ 

92 成本分析引擎 — 多维度聚合、趋势、预算管理。 

93 """ 

94 

95 def __init__( 

96 self, 

97 cost_tracker: CostTracker, 

98 budget_monthly: float = 0.0, 

99 warn_threshold: float = 0.8, 

100 persist_path: str = "", 

101 ): 

102 self.tracker = cost_tracker 

103 self.budget_monthly = budget_monthly 

104 self.warn_threshold = warn_threshold 

105 self.persist_path = persist_path 

106 self._entries: list[CostEntry] = [] 

107 self._lock = threading.Lock() 

108 self._load() 

109 

110 def record( 

111 self, 

112 model: str, 

113 session_id: str, 

114 input_tokens: int, 

115 output_tokens: int, 

116 duration_ms: float = 0.0, 

117 ): 

118 """记录一次调用成本。""" 

119 price = PRICING.get(model, {}) 

120 cost = ( 

121 input_tokens / 1_000_000 * price.get("input", 0) + 

122 output_tokens / 1_000_000 * price.get("output", 0) 

123 ) 

124 entry = CostEntry( 

125 timestamp=time.time(), 

126 model=model, 

127 session_id=session_id, 

128 input_tokens=input_tokens, 

129 output_tokens=output_tokens, 

130 cost_usd=cost, 

131 duration_ms=duration_ms, 

132 ) 

133 with self._lock: 

134 self._entries.append(entry) 

135 

136 # Periodic save (every 50 entries) 

137 if len(self._entries) % 50 == 0: 

138 self._save() 

139 

140 # ── 按模型汇总 ─────────────────────────────── 

141 

142 def by_model(self, hours: float = 24.0) -> list[dict]: 

143 """最近N小时的模型成本分布。""" 

144 cutoff = time.time() - hours * 3600 

145 agg: dict[str, dict] = defaultdict(lambda: { 

146 "model": "", "calls": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, 

147 }) 

148 with self._lock: 

149 for e in self._entries: 

150 if e.timestamp < cutoff: 

151 continue 

152 d = agg[e.model] 

153 d["model"] = e.model 

154 d["calls"] += 1 

155 d["input_tokens"] += e.input_tokens 

156 d["output_tokens"] += e.output_tokens 

157 d["cost_usd"] += e.cost_usd 

158 

159 return sorted(agg.values(), key=lambda x: x["cost_usd"], reverse=True) 

160 

161 # ── 按天汇总 ──────────────────────────────── 

162 

163 def daily_breakdown(self, days: int = 7) -> list[DailySummary]: 

164 """最近N天的每日成本明细。""" 

165 from datetime import datetime 

166 

167 today = datetime.now().date() 

168 summaries: dict[tuple[str, str], DailySummary] = {} 

169 

170 with self._lock: 

171 for e in self._entries: 

172 d = datetime.fromtimestamp(e.timestamp).strftime("%Y-%m-%d") 

173 key = (d, e.model) 

174 if key not in summaries: 

175 summaries[key] = DailySummary(date=d, model=e.model) 

176 s = summaries[key] 

177 s.calls += 1 

178 s.total_input_tokens += e.input_tokens 

179 s.total_output_tokens += e.output_tokens 

180 s.total_cost_usd += e.cost_usd 

181 if s.avg_duration_ms == 0: 

182 s.avg_duration_ms = e.duration_ms 

183 else: 

184 s.avg_duration_ms = (s.avg_duration_ms + e.duration_ms) / 2 

185 

186 # Sort by date desc, model 

187 result = sorted(summaries.values(), key=lambda x: (x.date, x.model), reverse=True) 

188 return result 

189 

190 # ── 按Session汇总 ──────────────────────────── 

191 

192 def by_session(self, top_n: int = 10) -> list[dict]: 

193 """最贵的N个session。""" 

194 agg: dict[str, dict] = defaultdict(lambda: {"session_id": "", "calls": 0, "cost_usd": 0.0, "tokens": 0}) 

195 with self._lock: 

196 for e in self._entries: 

197 d = agg[e.session_id] 

198 d["session_id"] = e.session_id 

199 d["calls"] += 1 

200 d["cost_usd"] += e.cost_usd 

201 d["tokens"] += e.input_tokens + e.output_tokens 

202 

203 sorted_sessions = sorted(agg.values(), key=lambda x: x["cost_usd"], reverse=True) 

204 return sorted_sessions[:top_n] 

205 

206 # ── 趋势分析 ───────────────────────────────── 

207 

208 def trend(self, metric: str = "cost", window: int = 7) -> list[dict]: 

209 """ 

210 趋势分析(滑动平均)。 

211 metric: cost | tokens | calls 

212 """ 

213 daily = self.daily_breakdown(days=window * 2) 

214 # Aggregate by date 

215 by_date: dict[str, dict] = defaultdict(lambda: {"date": "", "cost": 0.0, "tokens": 0, "calls": 0}) 

216 for s in daily: 

217 d = by_date[s.date] 

218 d["date"] = s.date 

219 d["cost"] += s.total_cost_usd 

220 d["tokens"] += s.total_input_tokens + s.total_output_tokens 

221 d["calls"] += s.calls 

222 

223 dates = sorted(by_date.keys())[-window * 2:] 

224 values = [by_date[d].get(metric, 0) for d in dates] 

225 

226 # Simple moving average (window=3) 

227 trend_data = [] 

228 for i, d in enumerate(dates): 

229 w_start = max(0, i - 2) 

230 w_vals = values[w_start:i + 1] 

231 trend_data.append({ 

232 "date": d, 

233 "value": values[i], 

234 "sma": sum(w_vals) / len(w_vals), 

235 }) 

236 return trend_data 

237 

238 # ── 预算预警 ───────────────────────────────── 

239 

240 def check_budget(self) -> BudgetAlert: 

241 """检查预算是否超过阈值。""" 

242 if self.budget_monthly <= 0: 

243 return BudgetAlert(False, self.tracker.total_cost, 0, 0, 0, "") 

244 

245 current = self.tracker.total_cost 

246 pct = current / self.budget_monthly 

247 

248 # Projection: based on past 7 days average 

249 daily_data = self.daily_breakdown(days=7) 

250 daily_costs = defaultdict(float) 

251 for s in daily_data: 

252 daily_costs[s.date] += s.total_cost_usd 

253 if daily_costs: 

254 avg_daily = sum(daily_costs.values()) / len(daily_costs) 

255 else: 

256 avg_daily = 0 

257 

258 from datetime import datetime 

259 days_left = 31 - datetime.now().day 

260 projected = avg_daily * max(days_left, 1) 

261 

262 triggered = pct > self.warn_threshold 

263 message = "" 

264 if triggered: 

265 projected_total = current + projected 

266 message = ( 

267 f"成本告警: 已消耗 ${current:.4f} ({pct:.0%})," 

268 f"预计月末 ${projected_total:.4f}" 

269 ) 

270 

271 return BudgetAlert( 

272 triggered=triggered, 

273 current_cost=current, 

274 budget=self.budget_monthly, 

275 pct_used=pct, 

276 projected_daily=avg_daily, 

277 message=message, 

278 ) 

279 

280 # ── Summary ────────────────────────────────── 

281 

282 @property 

283 def total_cost(self) -> float: 

284 return self.tracker.total_cost 

285 

286 @property 

287 def total_calls(self) -> int: 

288 with self._lock: 

289 return len(self._entries) 

290 

291 def summary(self) -> str: 

292 models = self.by_model(hours=24) 

293 lines = [ 

294 f"总成本: ${self.total_cost:.4f}", 

295 f"总调用: {self.total_calls} 次", 

296 "", 

297 "最近24h模型分布:", 

298 ] 

299 for m in models[:5]: 

300 lines.append( 

301 f" {m['model']}: ${m['cost_usd']:.4f} " 

302 f"({m['calls']}次, {m['input_tokens']}+{m['output_tokens']} tokens)" 

303 ) 

304 if self.budget_monthly > 0: 

305 alert = self.check_budget() 

306 lines.append(f"\n月度预算: ${self.budget_monthly:.2f} (已用 {alert.pct_used:.1%})") 

307 if alert.triggered: 

308 lines.append(f" {alert.message}") 

309 return "\n".join(lines) 

310 

311 # ── 详细分解 ───────────────────────────────── 

312 

313 def get_breakdown(self, session_id: str = "", hours: float = 24.0) -> list[CostBreakdown]: 

314 """获取指定会话或最近的详细成本分解。""" 

315 cutoff = time.time() - hours * 3600 

316 results = [] 

317 with self._lock: 

318 entries = self._entries 

319 if session_id: 

320 entries = [e for e in entries if e.session_id == session_id] 

321 for e in entries: 

322 if e.timestamp < cutoff: 

323 continue 

324 price = PRICING.get(e.model, {}) 

325 input_cost = e.input_tokens / 1_000_000 * price.get("input", 0) 

326 output_cost = e.output_tokens / 1_000_000 * price.get("output", 0) 

327 results.append(CostBreakdown( 

328 model=e.model, 

329 input_tokens=e.input_tokens, 

330 output_tokens=e.output_tokens, 

331 input_cost_usd=input_cost, 

332 output_cost_usd=output_cost, 

333 total_cost_usd=e.cost_usd, 

334 )) 

335 return sorted(results, key=lambda x: x.total_cost_usd, reverse=True) 

336 

337 def get_session(self, session_id: str) -> CostSession | None: 

338 """获取指定会话的成本摘要。""" 

339 with self._lock: 

340 matches = [e for e in self._entries if e.session_id == session_id] 

341 if not matches: 

342 return None 

343 models = set(e.model for e in matches) 

344 timestamps = [e.timestamp for e in matches] 

345 total_input = sum(e.input_tokens for e in matches) 

346 total_output = sum(e.output_tokens for e in matches) 

347 total_cost = sum(e.cost_usd for e in matches) 

348 return CostSession( 

349 session_id=session_id, 

350 model=", ".join(sorted(models)), 

351 calls=len(matches), 

352 total_input_tokens=total_input, 

353 total_output_tokens=total_output, 

354 total_cost_usd=total_cost, 

355 start_time=min(timestamps), 

356 end_time=max(timestamps), 

357 ) 

358 

359 # ── Scores 成本关联 ────────────────────────── 

360 

361 def cost_by_score_tier(self, scores: dict[float, list[str]]) -> dict[str, float]: 

362 """按评分层级聚合成本(与 ScoringEngine 联动)。""" 

363 tiers = {} 

364 for score, session_ids in scores.items(): 

365 tier_name = f"score_{score:.1f}" 

366 tier_cost = 0.0 

367 with self._lock: 

368 for e in self._entries: 

369 if e.session_id in session_ids: 

370 tier_cost += e.cost_usd 

371 tiers[tier_name] = tier_cost 

372 return tiers 

373 

374 # ── Persistence ────────────────────────────── 

375 

376 def _save(self): 

377 if not self.persist_path: 

378 return 

379 try: 

380 data = [] 

381 with self._lock: 

382 for e in self._entries[-10000:]: # Keep last 10k 

383 data.append({ 

384 "ts": e.timestamp, 

385 "model": e.model, 

386 "session_id": e.session_id, 

387 "input_tokens": e.input_tokens, 

388 "output_tokens": e.output_tokens, 

389 "cost_usd": e.cost_usd, 

390 "duration_ms": e.duration_ms, 

391 }) 

392 os.makedirs(os.path.dirname(self.persist_path) or ".", exist_ok=True) 

393 with open(self.persist_path, "w") as f: 

394 json.dump(data, f) 

395 except Exception: 

396 pass 

397 

398 def _load(self): 

399 if not self.persist_path or not os.path.exists(self.persist_path): 

400 return 

401 try: 

402 with open(self.persist_path) as f: 

403 data = json.load(f) 

404 with self._lock: 

405 self._entries = [ 

406 CostEntry( 

407 timestamp=d["ts"], model=d["model"], 

408 session_id=d["session_id"], 

409 input_tokens=d["input_tokens"], 

410 output_tokens=d["output_tokens"], 

411 cost_usd=d["cost_usd"], 

412 duration_ms=d.get("duration_ms", 0), 

413 ) 

414 for d in data 

415 ] 

416 except Exception: 

417 pass