chopratejas commited on
Commit
226e851
·
1 Parent(s): 8536550

Refactor: extract 7 modules from server.py (Steps 2-4)

Browse files

server.py: 8778 → 7412 lines (-1366, -15.5%)

Extracted modules:
- cost.py (629 lines): CostTracker, build_prefix_cache_stats, merge_cost_stats
- prometheus_metrics.py (312 lines): PrometheusMetrics
- semantic_cache.py (142 lines): SemanticCache
- rate_limiter.py (101 lines): TokenBucketRateLimiter
- request_logger.py (108 lines): RequestLogger
- helpers.py (195 lines): _read_request_json, constants, lazy loaders
- models.py (199 lines): ProxyConfig, RequestLog, CacheEntry (from Step 1)

All existing imports via headroom.proxy.server continue to work
through re-exports. Updated test patches to target new module paths.

181 tests pass, 0 regressions.

headroom/proxy/cost.py ADDED
@@ -0,0 +1,629 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cost tracking and budget management for the Headroom proxy.
2
+
3
+ Contains the CostTracker class and cost-related helper functions
4
+ for prefix cache statistics, cost merging, and session summaries.
5
+
6
+ Extracted from server.py for maintainability.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections import deque
13
+ from datetime import datetime, timedelta
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from headroom.proxy.prometheus_metrics import PrometheusMetrics
18
+
19
+ # Try to import LiteLLM for pricing
20
+ try:
21
+ import litellm
22
+
23
+ LITELLM_AVAILABLE = True
24
+ except ImportError:
25
+ LITELLM_AVAILABLE = False
26
+
27
+ logger = logging.getLogger("headroom.proxy")
28
+
29
+ # Provider-specific cache discount multipliers (what fraction of input price)
30
+ # Used to calculate dollar savings from prefix caching
31
+ _CACHE_ECONOMICS = {
32
+ "anthropic": {
33
+ "read_multiplier": 0.1,
34
+ "write_multiplier": 1.25,
35
+ "label": "Explicit breakpoints, 5-min TTL",
36
+ },
37
+ "openai": {
38
+ "read_multiplier": 0.5,
39
+ "write_multiplier": 1.0,
40
+ "label": "Automatic, no TTL control",
41
+ },
42
+ "gemini": {
43
+ "read_multiplier": 0.1,
44
+ "write_multiplier": 1.0,
45
+ "label": "Explicit cachedContent, configurable TTL",
46
+ },
47
+ "bedrock": {
48
+ "read_multiplier": 0.1,
49
+ "write_multiplier": 1.25,
50
+ "label": "Same as Anthropic (Bedrock)",
51
+ },
52
+ }
53
+
54
+
55
+ def _summarize_transforms(transforms: list[str]) -> str:
56
+ """Collapse repeated transforms into counted summary.
57
+
58
+ e.g. ['router:excluded:tool', 'router:excluded:tool', 'read_lifecycle:stale']
59
+ → 'router:excluded:tool*2 read_lifecycle:stale'
60
+ """
61
+ if not transforms:
62
+ return "none"
63
+ counts: dict[str, int] = {}
64
+ for t in transforms:
65
+ counts[t] = counts.get(t, 0) + 1
66
+ parts = [f"{k}*{v}" if v > 1 else k for k, v in counts.items()]
67
+ return " ".join(parts)
68
+
69
+
70
+ def build_prefix_cache_stats(
71
+ metrics: PrometheusMetrics,
72
+ cost_tracker: CostTracker | None,
73
+ ) -> dict:
74
+ """Build provider-aware prefix cache statistics for the dashboard."""
75
+ by_provider = {}
76
+ totals = {
77
+ "cache_read_tokens": 0,
78
+ "cache_write_tokens": 0,
79
+ "requests": 0,
80
+ "hit_requests": 0,
81
+ "bust_count": 0,
82
+ "bust_write_tokens": 0,
83
+ "savings_usd": 0.0,
84
+ "write_premium_usd": 0.0,
85
+ }
86
+
87
+ for provider, pc in metrics.cache_by_provider.items():
88
+ if pc["requests"] == 0:
89
+ continue
90
+
91
+ econ = _CACHE_ECONOMICS.get(provider, _CACHE_ECONOMICS["anthropic"])
92
+ read_mult: float = econ["read_multiplier"] # type: ignore[assignment]
93
+ write_mult: float = econ["write_multiplier"] # type: ignore[assignment]
94
+
95
+ # Get the base input price per token for the most-used model on this provider
96
+ input_price_per_token = None
97
+ if cost_tracker:
98
+ for model_name in cost_tracker._tokens_sent_by_model:
99
+ # Match model to provider
100
+ _openai_prefixes = ("gpt", "o1", "o3", "o4")
101
+ is_match = (
102
+ (provider == "anthropic" and "claude" in model_name)
103
+ or (provider == "openai" and any(p in model_name for p in _openai_prefixes))
104
+ or (provider == "gemini" and "gemini" in model_name)
105
+ or (provider == "bedrock" and "claude" in model_name)
106
+ )
107
+ if is_match:
108
+ price_per_1m = cost_tracker._get_list_price(model_name)
109
+ if price_per_1m:
110
+ input_price_per_token = price_per_1m / 1_000_000
111
+ break
112
+
113
+ # Calculate savings:
114
+ # Cache reads save (1.0 - read_mult) per token vs uncached input price.
115
+ # Cache write premium is NOT deducted — it's baseline cost that the
116
+ # client (e.g. Claude Code) pays regardless of Headroom. We track it
117
+ # for observability but don't penalise our savings number.
118
+ read_tokens: int = pc["cache_read_tokens"] # type: ignore[assignment]
119
+ write_tokens: int = pc["cache_write_tokens"] # type: ignore[assignment]
120
+ savings_usd = 0.0
121
+ write_premium_usd = 0.0
122
+
123
+ if input_price_per_token:
124
+ # Savings from reads: tokens * price * (1.0 - read_multiplier)
125
+ savings_usd = read_tokens * input_price_per_token * (1.0 - read_mult)
126
+ # Write premium (observability only — not subtracted from savings)
127
+ if write_mult > 1.0:
128
+ write_premium_usd = write_tokens * input_price_per_token * (write_mult - 1.0)
129
+
130
+ hit_rate = round(pc["hit_requests"] / pc["requests"] * 100, 1) if pc["requests"] > 0 else 0
131
+
132
+ provider_stats = {
133
+ "cache_read_tokens": read_tokens,
134
+ "cache_write_tokens": write_tokens,
135
+ "requests": pc["requests"],
136
+ "hit_requests": pc["hit_requests"],
137
+ "hit_rate": hit_rate,
138
+ "bust_count": pc["bust_count"],
139
+ "bust_write_tokens": pc["bust_write_tokens"],
140
+ "read_discount": f"{(1.0 - read_mult) * 100:.0f}%",
141
+ "write_premium": f"{(write_mult - 1.0) * 100:.0f}%" if write_mult > 1.0 else "none",
142
+ "savings_usd": round(savings_usd, 4),
143
+ "write_premium_usd": round(write_premium_usd, 4),
144
+ "net_savings_usd": round(savings_usd, 4),
145
+ "label": str(econ["label"]),
146
+ }
147
+ by_provider[provider] = provider_stats
148
+
149
+ # Accumulate totals
150
+ totals["cache_read_tokens"] += read_tokens
151
+ totals["cache_write_tokens"] += write_tokens
152
+ totals["requests"] += pc["requests"]
153
+ totals["hit_requests"] += pc["hit_requests"]
154
+ totals["bust_count"] += pc["bust_count"]
155
+ totals["bust_write_tokens"] += pc["bust_write_tokens"]
156
+ totals["savings_usd"] += savings_usd
157
+ totals["write_premium_usd"] += write_premium_usd
158
+
159
+ totals["net_savings_usd"] = round(totals["savings_usd"], 4)
160
+ totals["savings_usd"] = round(totals["savings_usd"], 4)
161
+ totals["write_premium_usd"] = round(totals["write_premium_usd"], 4)
162
+ totals["hit_rate"] = (
163
+ round(totals["hit_requests"] / totals["requests"] * 100, 1) if totals["requests"] > 0 else 0
164
+ )
165
+
166
+ return {
167
+ "by_provider": by_provider,
168
+ "totals": totals,
169
+ "prefix_freeze": {
170
+ "busts_avoided": metrics.prefix_freeze_busts_avoided,
171
+ "tokens_preserved": metrics.prefix_freeze_tokens_preserved,
172
+ "compression_foregone_tokens": metrics.prefix_freeze_compression_foregone,
173
+ "net_benefit_tokens": (
174
+ metrics.prefix_freeze_tokens_preserved - metrics.prefix_freeze_compression_foregone
175
+ ),
176
+ },
177
+ "attribution": (
178
+ "Prefix caching is performed by the LLM provider (Anthropic, OpenAI). "
179
+ "Headroom reports cache stats as observed from API responses. "
180
+ "CacheAligner and prefix freeze improve cache hit rates by stabilizing "
181
+ "the message prefix, but baseline caching happens without Headroom."
182
+ ),
183
+ }
184
+
185
+
186
+ def merge_cost_stats(
187
+ cost_stats: dict | None,
188
+ cache_stats: dict,
189
+ cli_tokens_avoided: int = 0,
190
+ ) -> dict | None:
191
+ """Merge compression, cache, and CLI savings into cost stats.
192
+
193
+ Each savings layer is reported separately with its own scope:
194
+ - savings_usd: compression savings at model list price (monotonic)
195
+ - cache_savings_usd: prefix cache discount from provider (separate)
196
+ - cli_tokens_avoided: tokens filtered by rtk (token count only, no $ estimate)
197
+
198
+ The hero metric (savings_usd) is ONLY compression savings priced at
199
+ the model's published input rate. Cache and CLI are shown separately.
200
+ This avoids the non-monotonic moving-average repricing bug (#83).
201
+ """
202
+ if cost_stats is None:
203
+ return None
204
+
205
+ cache_net = cache_stats.get("totals", {}).get("net_savings_usd", 0.0)
206
+ compression_savings = cost_stats.get("savings_usd", 0.0)
207
+
208
+ return {
209
+ **cost_stats,
210
+ "savings_usd": round(compression_savings, 4),
211
+ "compression_savings_usd": round(compression_savings, 4),
212
+ "cache_savings_usd": round(cache_net, 4),
213
+ "cli_tokens_avoided": cli_tokens_avoided,
214
+ }
215
+
216
+
217
+ def build_session_summary(
218
+ proxy: Any,
219
+ metrics: Any,
220
+ prefix_cache_stats: dict,
221
+ cli_tokens_avoided: int,
222
+ total_tokens_before: int,
223
+ ) -> dict[str, Any]:
224
+ """Build a human-readable session summary from metrics and request logs.
225
+
226
+ This is the headline view users see first in /stats — designed to answer
227
+ "is Headroom working?" at a glance.
228
+ """
229
+ # Analyze per-request compression from the logger
230
+ compressed_requests: list[dict] = []
231
+ uncompressed_reasons: dict[str, int] = {
232
+ "prefix_frozen": 0,
233
+ "too_small": 0,
234
+ "passthrough": 0,
235
+ "no_compressible_content": 0,
236
+ }
237
+
238
+ if proxy.logger:
239
+ for entry in proxy.logger._logs:
240
+ if entry.model and "count_tokens" in entry.model:
241
+ uncompressed_reasons["passthrough"] += 1
242
+ continue
243
+ if entry.tokens_saved > 0 and entry.savings_percent > 0:
244
+ compressed_requests.append(
245
+ {
246
+ "savings_pct": round(entry.savings_percent, 1),
247
+ "tokens_saved": entry.tokens_saved,
248
+ "original": entry.input_tokens_original,
249
+ "optimized": entry.input_tokens_optimized,
250
+ }
251
+ )
252
+ elif entry.input_tokens_original > 0:
253
+ # Categorize why it wasn't compressed
254
+ transforms = entry.transforms_applied or []
255
+ if not transforms:
256
+ # Pipeline returned unchanged — likely all frozen
257
+ uncompressed_reasons["prefix_frozen"] += 1
258
+ elif all("excluded" in t or "protected" in t for t in transforms):
259
+ uncompressed_reasons["no_compressible_content"] += 1
260
+ elif entry.input_tokens_original < 500:
261
+ uncompressed_reasons["too_small"] += 1
262
+ else:
263
+ uncompressed_reasons["prefix_frozen"] += 1
264
+
265
+ # Compute compression stats for requests that DID compress
266
+ avg_compression = 0.0
267
+ best_compression = 0.0
268
+ best_detail = ""
269
+ if compressed_requests:
270
+ avg_compression = round(
271
+ sum(r["savings_pct"] for r in compressed_requests) / len(compressed_requests),
272
+ 1,
273
+ )
274
+ best = max(compressed_requests, key=lambda r: r["savings_pct"])
275
+ best_compression = best["savings_pct"]
276
+ best_detail = f"{best['original']:,} → {best['optimized']:,} tokens"
277
+
278
+ # Cost summary — savings_usd is compression savings at model list price (monotonic)
279
+ cost_stats = proxy.cost_tracker.stats() if proxy.cost_tracker else {}
280
+ cost_with = cost_stats.get("cost_with_headroom_usd", 0.0)
281
+ compression_savings = cost_stats.get("savings_usd", 0.0)
282
+ cache_net = prefix_cache_stats.get("totals", {}).get("net_savings_usd", 0.0)
283
+ total_saved_usd = round(compression_savings, 2)
284
+ cost_without = cost_with + compression_savings
285
+ savings_pct_cost = round(total_saved_usd / cost_without * 100, 1) if cost_without > 0 else 0.0
286
+
287
+ # Primary models used
288
+ models = dict(metrics.requests_by_model)
289
+ primary_model = max(models, key=lambda k: models[k]) if models else "unknown"
290
+ api_requests = sum(v for k, v in models.items() if "count_tokens" not in k)
291
+
292
+ # Build the summary
293
+ summary: dict[str, Any] = {
294
+ "mode": proxy.config.mode,
295
+ "api_requests": api_requests,
296
+ "primary_model": primary_model,
297
+ "compression": {
298
+ "requests_compressed": len(compressed_requests),
299
+ "avg_compression_pct": avg_compression,
300
+ "best_compression_pct": best_compression,
301
+ "best_detail": best_detail,
302
+ "total_tokens_removed": metrics.tokens_saved_total,
303
+ },
304
+ "uncompressed_requests": {k: v for k, v in uncompressed_reasons.items() if v > 0},
305
+ "cost": {
306
+ "without_headroom_usd": round(cost_without, 2),
307
+ "with_headroom_usd": round(cost_with, 2),
308
+ "total_saved_usd": total_saved_usd,
309
+ "savings_pct": savings_pct_cost,
310
+ "breakdown": {
311
+ "cache_savings_usd": round(cache_net, 2),
312
+ "compression_savings_usd": round(compression_savings, 2),
313
+ },
314
+ },
315
+ }
316
+
317
+ # Add tip if token_headroom mode would help
318
+ if proxy.config.mode == "cost_savings" and uncompressed_reasons["prefix_frozen"] > 10:
319
+ summary["tip"] = (
320
+ "Most requests are prefix-frozen. Set HEADROOM_MODE=token_headroom "
321
+ "to compress frozen messages and extend your session by ~25-35%."
322
+ )
323
+
324
+ return summary
325
+
326
+
327
+ class CostTracker:
328
+ """Track costs and enforce budgets.
329
+
330
+ Cost history is automatically pruned to prevent unbounded memory growth:
331
+ - Entries older than 24 hours are removed
332
+ - Maximum of 100,000 entries are kept
333
+
334
+ Uses LiteLLM's community-maintained pricing database for accurate costs.
335
+ See: https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json
336
+ """
337
+
338
+ MAX_COST_ENTRIES = 100_000
339
+ COST_RETENTION_HOURS = 24
340
+
341
+ def __init__(self, budget_limit_usd: float | None = None, budget_period: str = "daily"):
342
+ self.budget_limit_usd = budget_limit_usd
343
+ self.budget_period = budget_period
344
+
345
+ # Cost tracking - using deque for efficient left-side removal
346
+ self._costs: deque[tuple[datetime, float]] = deque(maxlen=self.MAX_COST_ENTRIES)
347
+ self._last_prune_time: datetime = datetime.now()
348
+
349
+ # Token savings per model (exact, no dollar estimation)
350
+ self._tokens_saved_by_model: dict[str, int] = {}
351
+ self._tokens_sent_by_model: dict[str, int] = {}
352
+ self._requests_by_model: dict[str, int] = {}
353
+
354
+ # API-reported cache breakdown per model (for accurate cost calculation)
355
+ self._api_cache_read_by_model: dict[str, int] = {}
356
+ self._api_cache_write_by_model: dict[str, int] = {}
357
+ self._api_uncached_by_model: dict[str, int] = {}
358
+
359
+ # Cache resolved model names to avoid repeated litellm lookups.
360
+ # This is critical: litellm.cost_per_token() is synchronous and can block
361
+ # the async event loop if it triggers I/O (lazy model info download).
362
+ _resolved_model_cache: dict[str, str] = {}
363
+
364
+ @classmethod
365
+ def _resolve_litellm_model(cls, model: str) -> str:
366
+ """Resolve model name to one LiteLLM recognizes, adding provider prefix if needed.
367
+
368
+ Results are cached per model name to avoid blocking the event loop
369
+ with repeated synchronous litellm lookups.
370
+ """
371
+ if model in cls._resolved_model_cache:
372
+ return cls._resolved_model_cache[model]
373
+
374
+ resolved = cls._resolve_litellm_model_uncached(model)
375
+ cls._resolved_model_cache[model] = resolved
376
+ return resolved
377
+
378
+ @staticmethod
379
+ def _resolve_litellm_model_uncached(model: str) -> str:
380
+ """Uncached resolution — called once per unique model name."""
381
+ if not LITELLM_AVAILABLE:
382
+ return model
383
+
384
+ # Try as-is first
385
+ try:
386
+ litellm.cost_per_token(model=model, prompt_tokens=1, completion_tokens=0)
387
+ return model
388
+ except Exception:
389
+ pass
390
+
391
+ # Try with provider prefix
392
+ prefixes = {
393
+ "claude-": "anthropic/",
394
+ "gpt-": "openai/",
395
+ "o1-": "openai/",
396
+ "o3-": "openai/",
397
+ "o4-": "openai/",
398
+ "gemini-": "google/",
399
+ }
400
+ for pattern, prefix in prefixes.items():
401
+ if model.startswith(pattern):
402
+ prefixed = f"{prefix}{model}"
403
+ try:
404
+ litellm.cost_per_token(model=prefixed, prompt_tokens=1, completion_tokens=0)
405
+ return prefixed
406
+ except Exception:
407
+ break
408
+
409
+ return model
410
+
411
+ def estimate_cost(
412
+ self,
413
+ model: str,
414
+ input_tokens: int,
415
+ output_tokens: int,
416
+ cache_read_tokens: int = 0,
417
+ cache_write_tokens: int = 0,
418
+ ) -> float | None:
419
+ """Estimate cost in USD using LiteLLM's pricing database.
420
+
421
+ LiteLLM natively handles cache_read and cache_creation pricing
422
+ for all providers (Anthropic, OpenAI, Google, etc.) in a single call.
423
+
424
+ Args:
425
+ model: Model name for pricing lookup
426
+ input_tokens: Non-cached input tokens (excludes cache_read)
427
+ output_tokens: Output tokens
428
+ cache_read_tokens: Tokens served from cache (~10% of input rate)
429
+ cache_write_tokens: Tokens written to cache (~125% of input rate)
430
+ """
431
+ if not LITELLM_AVAILABLE:
432
+ logger.warning("LiteLLM not available - cannot calculate costs")
433
+ return None
434
+
435
+ try:
436
+ resolved_model = self._resolve_litellm_model(model)
437
+
438
+ # litellm.cost_per_token handles all token types natively:
439
+ # prompt_tokens at input rate, cache_read at ~10%, cache_creation at ~125%
440
+ input_cost, output_cost = litellm.cost_per_token(
441
+ model=resolved_model,
442
+ prompt_tokens=input_tokens,
443
+ completion_tokens=output_tokens,
444
+ cache_read_input_tokens=cache_read_tokens,
445
+ cache_creation_input_tokens=cache_write_tokens,
446
+ )
447
+
448
+ total_cost = input_cost + output_cost
449
+ return float(total_cost) if total_cost > 0 else None
450
+
451
+ except Exception as e:
452
+ logger.warning(f"Failed to get pricing for model {model}: {e}")
453
+ return None
454
+
455
+ def _prune_old_costs(self):
456
+ """Remove cost entries older than retention period.
457
+
458
+ Called periodically (every 5 minutes) to prevent unbounded memory growth.
459
+ The deque maxlen provides a hard cap, but time-based pruning keeps
460
+ memory usage proportional to actual traffic patterns.
461
+ """
462
+ now = datetime.now()
463
+ # Only prune every 5 minutes to avoid overhead
464
+ if (now - self._last_prune_time).total_seconds() < 300:
465
+ return
466
+
467
+ self._last_prune_time = now
468
+ cutoff = now - timedelta(hours=self.COST_RETENTION_HOURS)
469
+
470
+ # Remove entries from the left (oldest) while they're older than cutoff
471
+ while self._costs and self._costs[0][0] < cutoff:
472
+ self._costs.popleft()
473
+
474
+ def record_tokens(
475
+ self,
476
+ model: str,
477
+ tokens_saved: int,
478
+ tokens_sent: int,
479
+ cache_read_tokens: int = 0,
480
+ cache_write_tokens: int = 0,
481
+ uncached_tokens: int = 0,
482
+ ):
483
+ """Record token counts per model.
484
+
485
+ Args:
486
+ model: Model name.
487
+ tokens_saved: Tokens removed by compression (Headroom's count).
488
+ tokens_sent: Compressed message tokens sent (Headroom's count).
489
+ cache_read_tokens: Cache read tokens from API response usage.
490
+ cache_write_tokens: Cache write tokens from API response usage.
491
+ uncached_tokens: Non-cached input tokens from API response usage.
492
+ """
493
+ self._tokens_saved_by_model[model] = (
494
+ self._tokens_saved_by_model.get(model, 0) + tokens_saved
495
+ )
496
+ self._tokens_sent_by_model[model] = self._tokens_sent_by_model.get(model, 0) + tokens_sent
497
+ self._requests_by_model[model] = self._requests_by_model.get(model, 0) + 1
498
+ self._api_cache_read_by_model[model] = (
499
+ self._api_cache_read_by_model.get(model, 0) + cache_read_tokens
500
+ )
501
+ self._api_cache_write_by_model[model] = (
502
+ self._api_cache_write_by_model.get(model, 0) + cache_write_tokens
503
+ )
504
+ self._api_uncached_by_model[model] = (
505
+ self._api_uncached_by_model.get(model, 0) + uncached_tokens
506
+ )
507
+
508
+ def get_period_cost(self) -> float:
509
+ """Get cost for current budget period."""
510
+ now = datetime.now()
511
+
512
+ if self.budget_period == "hourly":
513
+ cutoff = now - timedelta(hours=1)
514
+ elif self.budget_period == "daily":
515
+ cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0)
516
+ else: # monthly
517
+ cutoff = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
518
+
519
+ return sum(cost for ts, cost in self._costs if ts >= cutoff)
520
+
521
+ def check_budget(self) -> tuple[bool, float]:
522
+ """Check if within budget. Returns (allowed, remaining)."""
523
+ if self.budget_limit_usd is None:
524
+ return True, float("inf")
525
+
526
+ period_cost = self.get_period_cost()
527
+ remaining = self.budget_limit_usd - period_cost
528
+ return remaining > 0, max(0, remaining)
529
+
530
+ def _get_list_price(self, model: str) -> float | None:
531
+ """Get list input price per 1M tokens for a model."""
532
+ if not LITELLM_AVAILABLE:
533
+ return None
534
+ try:
535
+ resolved = self._resolve_litellm_model(model)
536
+ info = litellm.model_cost.get(resolved, {})
537
+ cost_per_token = info.get("input_cost_per_token")
538
+ return cost_per_token * 1_000_000 if cost_per_token else None
539
+ except Exception:
540
+ return None
541
+
542
+ def _get_cache_prices(self, model: str) -> tuple[float, float, float] | None:
543
+ """Get per-token prices for cache read, cache write, and uncached input.
544
+
545
+ Returns (cache_read, cache_write, uncached) per-token costs, or None
546
+ if pricing is unavailable. Uses LiteLLM's native cache pricing data.
547
+ """
548
+ if not LITELLM_AVAILABLE:
549
+ return None
550
+ try:
551
+ resolved = self._resolve_litellm_model(model)
552
+ info = litellm.model_cost.get(resolved, {})
553
+ uncached = info.get("input_cost_per_token")
554
+ if not uncached:
555
+ return None
556
+ cache_read = info.get("cache_read_input_token_cost", uncached)
557
+ cache_write = info.get("cache_creation_input_token_cost", uncached)
558
+ return (cache_read, cache_write, uncached)
559
+ except Exception:
560
+ return None
561
+
562
+ def stats(self) -> dict:
563
+ """Get token statistics per model."""
564
+ per_model = {}
565
+ total_saved = 0
566
+ for model in sorted(self._tokens_saved_by_model.keys()):
567
+ saved = self._tokens_saved_by_model[model]
568
+ sent = self._tokens_sent_by_model.get(model, 0)
569
+ reqs = self._requests_by_model.get(model, 0)
570
+ total_saved += saved
571
+ per_model[model] = {
572
+ "requests": reqs,
573
+ "tokens_saved": saved,
574
+ "tokens_sent": sent,
575
+ "reduction_pct": round(saved / (saved + sent) * 100, 1)
576
+ if (saved + sent) > 0
577
+ else 0,
578
+ }
579
+
580
+ # Compute actual input cost using API-reported cache breakdown and
581
+ # LiteLLM's per-category pricing (cache reads discounted, writes at
582
+ # premium, uncached at list). Falls back to list price when cache
583
+ # data is unavailable.
584
+ cost_with_headroom = 0.0
585
+ total_billed_input_tokens = 0
586
+ total_input_tokens = 0
587
+ for model in self._tokens_saved_by_model:
588
+ saved = self._tokens_saved_by_model[model]
589
+ sent = self._tokens_sent_by_model.get(model, 0)
590
+ cr = self._api_cache_read_by_model.get(model, 0)
591
+ cw = self._api_cache_write_by_model.get(model, 0)
592
+ uncached = self._api_uncached_by_model.get(model, 0)
593
+ total_input_tokens += sent
594
+
595
+ prices = self._get_cache_prices(model)
596
+ if prices:
597
+ cr_price, cw_price, uncached_price = prices
598
+ if cr + cw + uncached > 0:
599
+ # Use API's real cache breakdown with LiteLLM pricing
600
+ model_cost = cr * cr_price + cw * cw_price + uncached * uncached_price
601
+ billed_tokens = cr + cw + uncached
602
+ else:
603
+ # No cache data from API — fall back to list price
604
+ model_cost = sent * uncached_price
605
+ billed_tokens = sent
606
+ cost_with_headroom += model_cost
607
+ total_billed_input_tokens += billed_tokens
608
+
609
+ # Compression savings: price saved tokens at the model's list input price.
610
+ # This is simple, monotonic, and transparent — each saved token is valued
611
+ # at the published $/token rate for its model. Not affected by cache mix.
612
+ savings_usd = 0.0
613
+ for model in self._tokens_saved_by_model:
614
+ saved = self._tokens_saved_by_model[model]
615
+ if saved <= 0:
616
+ continue
617
+ prices = self._get_cache_prices(model)
618
+ if prices:
619
+ _cr_price, _cw_price, uncached_price = prices
620
+ savings_usd += saved * uncached_price
621
+
622
+ return {
623
+ "total_tokens_saved": total_saved,
624
+ "total_input_tokens": total_input_tokens,
625
+ "total_input_cost_usd": round(cost_with_headroom, 4),
626
+ "per_model": per_model,
627
+ "cost_with_headroom_usd": round(cost_with_headroom, 4),
628
+ "savings_usd": round(savings_usd, 4),
629
+ }
headroom/proxy/helpers.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Top-level helper functions and constants for the Headroom proxy.
2
+
3
+ Contains lazy loaders, file logging setup, request body decompression,
4
+ and safety-limit constants.
5
+
6
+ Extracted from server.py for maintainability.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from fastapi import Request
18
+
19
+ logger = logging.getLogger("headroom.proxy")
20
+
21
+ # Maximum request body size (100MB - increased to support image-heavy requests)
22
+ MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
23
+
24
+ # Maximum SSE buffer size (10MB - prevents memory exhaustion from malformed streams)
25
+ MAX_SSE_BUFFER_SIZE = 10 * 1024 * 1024
26
+
27
+ # Maximum message array length (prevents DoS from deeply nested payloads)
28
+ MAX_MESSAGE_ARRAY_LENGTH = 10000
29
+
30
+ # Compression pipeline timeout in seconds
31
+ COMPRESSION_TIMEOUT_SECONDS = 30
32
+
33
+ # Maximum compression cache sessions (prevents unbounded memory growth)
34
+ MAX_COMPRESSION_CACHE_SESSIONS = 500
35
+
36
+ # Image compression (lazy-loaded to avoid heavy dependencies at startup)
37
+ _image_compressor = None
38
+
39
+
40
+ def _get_image_compressor():
41
+ """Lazy load image compressor to avoid startup overhead."""
42
+ global _image_compressor
43
+ if _image_compressor is None:
44
+ try:
45
+ from headroom.image import ImageCompressor
46
+
47
+ _image_compressor = ImageCompressor()
48
+ logger.info("Image compression enabled (model: chopratejas/technique-router)")
49
+ except ImportError as e:
50
+ logger.warning(f"Image compression not available: {e}")
51
+ _image_compressor = False # Mark as unavailable
52
+ return _image_compressor if _image_compressor else None
53
+
54
+
55
+ # Always-on file logging to ~/.headroom/logs/ for `headroom perf` analysis
56
+ _HEADROOM_LOG_DIR = Path.home() / ".headroom" / "logs"
57
+
58
+
59
+ def _setup_file_logging() -> None:
60
+ """Add a RotatingFileHandler to the headroom root logger.
61
+
62
+ Writes to ~/.headroom/logs/proxy.log with automatic rotation:
63
+ - Rotates at 10 MB
64
+ - Keeps 5 backups (~50 MB max)
65
+ """
66
+ from logging.handlers import RotatingFileHandler
67
+
68
+ try:
69
+ _HEADROOM_LOG_DIR.mkdir(parents=True, exist_ok=True)
70
+ log_path = _HEADROOM_LOG_DIR / "proxy.log"
71
+ handler = RotatingFileHandler(
72
+ log_path,
73
+ maxBytes=10 * 1024 * 1024, # 10 MB
74
+ backupCount=5,
75
+ encoding="utf-8",
76
+ )
77
+ handler.setLevel(logging.INFO)
78
+ handler.setFormatter(
79
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
80
+ )
81
+ # Attach to the headroom root logger so all sub-loggers are captured
82
+ logging.getLogger("headroom").addHandler(handler)
83
+ except OSError:
84
+ # Non-fatal: can't write logs (read-only fs, permissions, etc.)
85
+ pass
86
+
87
+
88
+ def _get_rtk_stats() -> dict[str, Any] | None:
89
+ """Get rtk (Rust Token Killer) savings stats if rtk is installed.
90
+
91
+ Reads from rtk's tracking database via `rtk gain --format json`.
92
+ Returns None if rtk is not installed.
93
+ """
94
+ import shutil
95
+ import subprocess as _sp
96
+
97
+ rtk_bin = shutil.which("rtk")
98
+ if not rtk_bin:
99
+ # Check headroom-managed install
100
+ rtk_managed = Path.home() / ".headroom" / "bin" / "rtk"
101
+ if rtk_managed.exists():
102
+ rtk_bin = str(rtk_managed)
103
+ else:
104
+ return None
105
+
106
+ try:
107
+ result = _sp.run(
108
+ [rtk_bin, "gain", "--format", "json"],
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=5,
112
+ )
113
+ if result.returncode == 0 and result.stdout.strip():
114
+ data = json.loads(result.stdout)
115
+ summary = data.get("summary", {})
116
+ return {
117
+ "installed": True,
118
+ "total_commands": summary.get("total_commands", 0),
119
+ "tokens_saved": summary.get("total_saved", 0),
120
+ "avg_savings_pct": summary.get("avg_savings_pct", 0.0),
121
+ }
122
+ except Exception:
123
+ pass
124
+
125
+ return {"installed": True, "total_commands": 0, "tokens_saved": 0, "avg_savings_pct": 0.0}
126
+
127
+
128
+ async def _read_request_json(request: Request) -> dict[str, Any]:
129
+ """Read and parse JSON from a request, handling compressed bodies.
130
+
131
+ Clients like OpenAI Codex may send zstd, gzip, or deflate-compressed
132
+ request bodies. Starlette's ``request.json()`` does not decompress
133
+ automatically, causing a UnicodeDecodeError on compressed bytes.
134
+
135
+ This helper inspects ``Content-Encoding``, decompresses if needed,
136
+ then JSON-decodes the result. It raises ``ValueError`` on any
137
+ decompression or parse failure so callers can return a clean 400.
138
+ """
139
+ encoding = (request.headers.get("content-encoding") or "").lower().strip()
140
+ raw = await request.body()
141
+
142
+ if encoding in ("zstd", "zstandard"):
143
+ try:
144
+ import zstandard
145
+
146
+ dctx = zstandard.ZstdDecompressor()
147
+ # Use stream_reader for streaming zstd frames (no content size in header).
148
+ # Plain decompress() fails when the frame header omits the size, which
149
+ # is common with clients like OpenAI Codex.
150
+ reader = dctx.stream_reader(raw)
151
+ raw = reader.read()
152
+ reader.close()
153
+ except ImportError:
154
+ raise ValueError(
155
+ "Request body is zstd-compressed but the 'zstandard' package is not installed. "
156
+ "Install it with: pip install zstandard"
157
+ ) from None
158
+ except Exception as exc:
159
+ raise ValueError(f"Failed to decompress zstd request body: {exc}") from exc
160
+ elif encoding == "gzip":
161
+ import gzip as _gzip
162
+
163
+ try:
164
+ raw = _gzip.decompress(raw)
165
+ except Exception as exc:
166
+ raise ValueError(f"Failed to decompress gzip request body: {exc}") from exc
167
+ elif encoding == "deflate":
168
+ import zlib
169
+
170
+ try:
171
+ raw = zlib.decompress(raw)
172
+ except Exception as exc:
173
+ raise ValueError(f"Failed to decompress deflate request body: {exc}") from exc
174
+ elif encoding == "br":
175
+ try:
176
+ import brotli
177
+
178
+ raw = brotli.decompress(raw)
179
+ except ImportError:
180
+ raise ValueError(
181
+ "Request body is brotli-compressed but the 'brotli' package is not installed."
182
+ ) from None
183
+ except Exception as exc:
184
+ raise ValueError(f"Failed to decompress brotli request body: {exc}") from exc
185
+ elif encoding and encoding != "identity":
186
+ raise ValueError(f"Unsupported Content-Encoding: {encoding}")
187
+
188
+ # Decode and parse JSON
189
+ try:
190
+ text = raw.decode("utf-8")
191
+ except UnicodeDecodeError as exc:
192
+ raise ValueError(f"Request body is not valid UTF-8 (possibly compressed?): {exc}") from exc
193
+
194
+ result: dict[str, Any] = json.loads(text)
195
+ return result
headroom/proxy/prometheus_metrics.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prometheus-compatible metrics for the Headroom proxy.
2
+
3
+ Tracks request counts, token usage, latency, overhead, TTFB,
4
+ per-transform timing, waste signals, prefix cache stats, and
5
+ cumulative savings history.
6
+
7
+ Extracted from server.py for maintainability.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from collections import defaultdict
15
+ from datetime import datetime
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from headroom.proxy.cost import CostTracker
20
+
21
+ from headroom.proxy.savings_tracker import SavingsTracker
22
+
23
+ logger = logging.getLogger("headroom.proxy")
24
+
25
+
26
+ class PrometheusMetrics:
27
+ """Prometheus-compatible metrics."""
28
+
29
+ def __init__(
30
+ self,
31
+ savings_tracker: SavingsTracker | None = None,
32
+ cost_tracker: CostTracker | None = None,
33
+ ):
34
+ self.requests_total = 0
35
+ self.requests_by_provider: dict[str, int] = defaultdict(int)
36
+ self.requests_by_model: dict[str, int] = defaultdict(int)
37
+ self.requests_cached = 0
38
+ self.requests_rate_limited = 0
39
+ self.requests_failed = 0
40
+
41
+ self.tokens_input_total = 0
42
+ self.tokens_output_total = 0
43
+ self.tokens_saved_total = 0
44
+
45
+ self.latency_sum_ms = 0.0
46
+ self.latency_min_ms = float("inf")
47
+ self.latency_max_ms = 0.0
48
+ self.latency_count = 0
49
+
50
+ # Headroom overhead (optimization time only, excludes LLM)
51
+ self.overhead_sum_ms = 0.0
52
+ self.overhead_min_ms = float("inf")
53
+ self.overhead_max_ms = 0.0
54
+ self.overhead_count = 0
55
+
56
+ # Time to first byte (TTFB) from upstream — what the user actually feels
57
+ self.ttfb_sum_ms = 0.0
58
+ self.ttfb_min_ms = float("inf")
59
+ self.ttfb_max_ms = 0.0
60
+ self.ttfb_count = 0
61
+
62
+ # Per-transform timing (name → cumulative ms, count)
63
+ self.transform_timing_sum: dict[str, float] = defaultdict(float)
64
+ self.transform_timing_count: dict[str, int] = defaultdict(int)
65
+ self.transform_timing_max: dict[str, float] = defaultdict(float)
66
+
67
+ # Aggregate waste signals
68
+ self.waste_signals_total: dict[str, int] = defaultdict(int)
69
+
70
+ # Provider-specific prefix cache tracking
71
+ # Each provider has different cache economics:
72
+ # Anthropic: cache_read=0.1x, cache_write=1.25x, explicit breakpoints
73
+ # OpenAI: cache_read=0.5x, no write penalty, automatic
74
+ # Google: cache_read=~0.1x, explicit cachedContent API, storage cost
75
+ # Bedrock: no cache metrics
76
+ self.cache_by_provider: dict[str, dict[str, int | float]] = defaultdict(
77
+ lambda: {
78
+ "cache_read_tokens": 0,
79
+ "cache_write_tokens": 0,
80
+ "requests": 0,
81
+ "hit_requests": 0, # requests with cache_read > 0
82
+ "bust_count": 0,
83
+ "bust_write_tokens": 0,
84
+ }
85
+ )
86
+ # Track per-model cache request count to distinguish cold starts from busts
87
+ self._cache_requests_by_model: dict[str, int] = defaultdict(int)
88
+
89
+ # Prefix freeze stats (cache-aware compression)
90
+ self.prefix_freeze_busts_avoided: int = 0
91
+ self.prefix_freeze_tokens_preserved: int = 0
92
+ self.prefix_freeze_compression_foregone: int = 0
93
+
94
+ # Cumulative savings history (timestamp → cumulative tokens saved)
95
+ self.savings_history: list[tuple[str, int]] = []
96
+ self.savings_tracker = savings_tracker or SavingsTracker()
97
+ self.cost_tracker = cost_tracker
98
+ tracker_lifetime = self.savings_tracker.snapshot()["lifetime"]
99
+ self._savings_tracker_input_tokens_offset = max(
100
+ int(tracker_lifetime.get("total_input_tokens", 0) or 0),
101
+ 0,
102
+ )
103
+ self._savings_tracker_input_cost_usd_offset = max(
104
+ float(tracker_lifetime.get("total_input_cost_usd", 0.0) or 0.0),
105
+ 0.0,
106
+ )
107
+
108
+ self._lock = asyncio.Lock()
109
+
110
+ def _current_savings_tracker_totals(self) -> tuple[int, float]:
111
+ total_input_tokens = self._savings_tracker_input_tokens_offset + self.tokens_input_total
112
+ total_input_cost_usd = self._savings_tracker_input_cost_usd_offset
113
+
114
+ if self.cost_tracker is None:
115
+ return total_input_tokens, total_input_cost_usd
116
+
117
+ try:
118
+ cost_stats = self.cost_tracker.stats()
119
+ except Exception:
120
+ logger.debug("Failed to read cost tracker totals for savings history", exc_info=True)
121
+ return total_input_tokens, total_input_cost_usd
122
+
123
+ tracked_input_tokens = cost_stats.get("total_input_tokens")
124
+ tracked_input_cost_usd = cost_stats.get("total_input_cost_usd")
125
+
126
+ if tracked_input_tokens is not None:
127
+ try:
128
+ total_input_tokens = self._savings_tracker_input_tokens_offset + max(
129
+ int(tracked_input_tokens),
130
+ 0,
131
+ )
132
+ except (TypeError, ValueError):
133
+ pass
134
+
135
+ if tracked_input_cost_usd is not None:
136
+ try:
137
+ total_input_cost_usd = self._savings_tracker_input_cost_usd_offset + max(
138
+ float(tracked_input_cost_usd),
139
+ 0.0,
140
+ )
141
+ except (TypeError, ValueError):
142
+ pass
143
+
144
+ return total_input_tokens, total_input_cost_usd
145
+
146
+ async def record_request(
147
+ self,
148
+ provider: str,
149
+ model: str,
150
+ input_tokens: int,
151
+ output_tokens: int,
152
+ tokens_saved: int,
153
+ latency_ms: float,
154
+ cached: bool = False,
155
+ overhead_ms: float = 0,
156
+ ttfb_ms: float = 0,
157
+ pipeline_timing: dict[str, float] | None = None,
158
+ waste_signals: dict[str, int] | None = None,
159
+ cache_read_tokens: int = 0,
160
+ cache_write_tokens: int = 0,
161
+ uncached_input_tokens: int = 0,
162
+ ):
163
+ """Record metrics for a request."""
164
+ async with self._lock:
165
+ self.requests_total += 1
166
+ self.requests_by_provider[provider] += 1
167
+ self.requests_by_model[model] += 1
168
+
169
+ if cached:
170
+ self.requests_cached += 1
171
+
172
+ self.tokens_input_total += input_tokens
173
+ self.tokens_output_total += output_tokens
174
+ self.tokens_saved_total += tokens_saved
175
+
176
+ # Track provider-specific prefix cache metrics
177
+ if cache_read_tokens > 0 or cache_write_tokens > 0:
178
+ pc = self.cache_by_provider[provider]
179
+ pc["cache_read_tokens"] += cache_read_tokens
180
+ pc["cache_write_tokens"] += cache_write_tokens
181
+ pc["requests"] += 1
182
+ if cache_read_tokens > 0:
183
+ pc["hit_requests"] += 1
184
+ # Model-aware bust detection: the first request for any model
185
+ # is always a cold start (100% write, 0% read) — not a bust.
186
+ # Only flag as bust when a previously-warm model suddenly has
187
+ # high write ratio, indicating prefix invalidation.
188
+ model_req_num = self._cache_requests_by_model[model]
189
+ self._cache_requests_by_model[model] += 1
190
+ if provider == "anthropic" and model_req_num > 0:
191
+ total_cached = cache_read_tokens + cache_write_tokens
192
+ if total_cached > 0 and cache_write_tokens > total_cached * 0.5:
193
+ pc["bust_count"] += 1
194
+ pc["bust_write_tokens"] += cache_write_tokens
195
+
196
+ self.latency_sum_ms += latency_ms
197
+ self.latency_min_ms = min(self.latency_min_ms, latency_ms)
198
+ self.latency_max_ms = max(self.latency_max_ms, latency_ms)
199
+ self.latency_count += 1
200
+
201
+ # Track Headroom overhead separately
202
+ if overhead_ms > 0:
203
+ self.overhead_sum_ms += overhead_ms
204
+ self.overhead_min_ms = min(self.overhead_min_ms, overhead_ms)
205
+ self.overhead_max_ms = max(self.overhead_max_ms, overhead_ms)
206
+ self.overhead_count += 1
207
+
208
+ # Track TTFB (time to first byte from upstream)
209
+ if ttfb_ms > 0:
210
+ self.ttfb_sum_ms += ttfb_ms
211
+ self.ttfb_min_ms = min(self.ttfb_min_ms, ttfb_ms)
212
+ self.ttfb_max_ms = max(self.ttfb_max_ms, ttfb_ms)
213
+ self.ttfb_count += 1
214
+
215
+ # Track per-transform timing
216
+ if pipeline_timing:
217
+ for name, ms in pipeline_timing.items():
218
+ self.transform_timing_sum[name] += ms
219
+ self.transform_timing_count[name] += 1
220
+ self.transform_timing_max[name] = max(self.transform_timing_max[name], ms)
221
+
222
+ # Track waste signals
223
+ if waste_signals:
224
+ for signal_name, token_count in waste_signals.items():
225
+ self.waste_signals_total[signal_name] += token_count
226
+
227
+ # Track cumulative savings history (record every request)
228
+ self.savings_history.append((datetime.now().isoformat(), self.tokens_saved_total))
229
+ # Keep last 500 data points
230
+ if len(self.savings_history) > 500:
231
+ self.savings_history = self.savings_history[-500:]
232
+
233
+ total_input_tokens, total_input_cost_usd = self._current_savings_tracker_totals()
234
+ self.savings_tracker.record_request(
235
+ model=model,
236
+ input_tokens=input_tokens,
237
+ tokens_saved=tokens_saved,
238
+ cache_read_tokens=cache_read_tokens,
239
+ cache_write_tokens=cache_write_tokens,
240
+ uncached_input_tokens=uncached_input_tokens,
241
+ total_input_tokens=total_input_tokens,
242
+ total_input_cost_usd=total_input_cost_usd,
243
+ )
244
+
245
+ async def record_rate_limited(self):
246
+ async with self._lock:
247
+ self.requests_rate_limited += 1
248
+
249
+ async def record_failed(self):
250
+ async with self._lock:
251
+ self.requests_failed += 1
252
+
253
+ async def export(self) -> str:
254
+ """Export metrics in Prometheus format."""
255
+ async with self._lock:
256
+ lines = [
257
+ "# HELP headroom_requests_total Total number of requests",
258
+ "# TYPE headroom_requests_total counter",
259
+ f"headroom_requests_total {self.requests_total}",
260
+ "",
261
+ "# HELP headroom_requests_cached_total Cached request count",
262
+ "# TYPE headroom_requests_cached_total counter",
263
+ f"headroom_requests_cached_total {self.requests_cached}",
264
+ "",
265
+ "# HELP headroom_requests_rate_limited_total Rate limited requests",
266
+ "# TYPE headroom_requests_rate_limited_total counter",
267
+ f"headroom_requests_rate_limited_total {self.requests_rate_limited}",
268
+ "",
269
+ "# HELP headroom_requests_failed_total Failed requests",
270
+ "# TYPE headroom_requests_failed_total counter",
271
+ f"headroom_requests_failed_total {self.requests_failed}",
272
+ "",
273
+ "# HELP headroom_tokens_input_total Total input tokens",
274
+ "# TYPE headroom_tokens_input_total counter",
275
+ f"headroom_tokens_input_total {self.tokens_input_total}",
276
+ "",
277
+ "# HELP headroom_tokens_output_total Total output tokens",
278
+ "# TYPE headroom_tokens_output_total counter",
279
+ f"headroom_tokens_output_total {self.tokens_output_total}",
280
+ "",
281
+ "# HELP headroom_tokens_saved_total Tokens saved by optimization",
282
+ "# TYPE headroom_tokens_saved_total counter",
283
+ f"headroom_tokens_saved_total {self.tokens_saved_total}",
284
+ "",
285
+ "# HELP headroom_latency_ms_sum Sum of request latencies",
286
+ "# TYPE headroom_latency_ms_sum counter",
287
+ f"headroom_latency_ms_sum {self.latency_sum_ms:.2f}",
288
+ ]
289
+
290
+ # Per-provider metrics
291
+ lines.extend(
292
+ [
293
+ "",
294
+ "# HELP headroom_requests_by_provider Requests by provider",
295
+ "# TYPE headroom_requests_by_provider counter",
296
+ ]
297
+ )
298
+ for provider, count in self.requests_by_provider.items():
299
+ lines.append(f'headroom_requests_by_provider{{provider="{provider}"}} {count}')
300
+
301
+ # Per-model metrics
302
+ lines.extend(
303
+ [
304
+ "",
305
+ "# HELP headroom_requests_by_model Requests by model",
306
+ "# TYPE headroom_requests_by_model counter",
307
+ ]
308
+ )
309
+ for model, count in self.requests_by_model.items():
310
+ lines.append(f'headroom_requests_by_model{{model="{model}"}} {count}')
311
+
312
+ return "\n".join(lines)
headroom/proxy/rate_limiter.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Token bucket rate limiter for the Headroom proxy.
2
+
3
+ Rate limits requests and token usage per API key or IP address.
4
+
5
+ Extracted from server.py for maintainability.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import time
13
+ from collections import defaultdict
14
+
15
+ from headroom.proxy.models import RateLimitState
16
+
17
+ logger = logging.getLogger("headroom.proxy")
18
+
19
+ # Maximum rate limiter buckets (prevents DoS via spoofed API keys)
20
+ MAX_RATE_LIMITER_BUCKETS = 1000
21
+
22
+
23
+ class TokenBucketRateLimiter:
24
+ """Token bucket rate limiter for requests and tokens."""
25
+
26
+ def __init__(
27
+ self,
28
+ requests_per_minute: int = 60,
29
+ tokens_per_minute: int = 100000,
30
+ ):
31
+ self.requests_per_minute = requests_per_minute
32
+ self.tokens_per_minute = tokens_per_minute
33
+
34
+ # Per-key buckets (key = API key or IP)
35
+ self._request_buckets: dict[str, RateLimitState] = defaultdict(
36
+ lambda: RateLimitState(tokens=requests_per_minute, last_update=time.time())
37
+ )
38
+ self._token_buckets: dict[str, RateLimitState] = defaultdict(
39
+ lambda: RateLimitState(tokens=tokens_per_minute, last_update=time.time())
40
+ )
41
+ self._lock = asyncio.Lock()
42
+
43
+ async def _cleanup_stale_buckets(self) -> None:
44
+ """Remove buckets that haven't been used in the last 10 minutes."""
45
+ now = time.time()
46
+ stale_threshold = now - 600 # 10 minutes
47
+ stale_keys = [
48
+ k for k, v in self._request_buckets.items() if v.last_update < stale_threshold
49
+ ]
50
+ for k in stale_keys:
51
+ del self._request_buckets[k]
52
+ self._token_buckets.pop(k, None)
53
+ if stale_keys:
54
+ logger.debug(f"Cleaned up {len(stale_keys)} stale rate limiter buckets")
55
+
56
+ def _refill(self, state: RateLimitState, rate_per_minute: float) -> float:
57
+ """Refill bucket based on elapsed time."""
58
+ now = time.time()
59
+ elapsed = now - state.last_update
60
+ refill = elapsed * (rate_per_minute / 60.0)
61
+ state.tokens = min(rate_per_minute, state.tokens + refill)
62
+ state.last_update = now
63
+ return state.tokens
64
+
65
+ async def check_request(self, key: str = "default") -> tuple[bool, float]:
66
+ """Check if request is allowed. Returns (allowed, wait_seconds)."""
67
+ async with self._lock:
68
+ # Prevent unbounded bucket growth from spoofed keys
69
+ if len(self._request_buckets) > MAX_RATE_LIMITER_BUCKETS:
70
+ await self._cleanup_stale_buckets()
71
+ state = self._request_buckets[key]
72
+ available = self._refill(state, self.requests_per_minute)
73
+
74
+ if available >= 1:
75
+ state.tokens -= 1
76
+ return True, 0
77
+
78
+ wait_seconds = (1 - available) * (60.0 / self.requests_per_minute)
79
+ return False, wait_seconds
80
+
81
+ async def check_tokens(self, key: str, token_count: int) -> tuple[bool, float]:
82
+ """Check if token usage is allowed."""
83
+ async with self._lock:
84
+ state = self._token_buckets[key]
85
+ available = self._refill(state, self.tokens_per_minute)
86
+
87
+ if available >= token_count:
88
+ state.tokens -= token_count
89
+ return True, 0
90
+
91
+ wait_seconds = (token_count - available) * (60.0 / self.tokens_per_minute)
92
+ return False, wait_seconds
93
+
94
+ async def stats(self) -> dict:
95
+ """Get rate limiter statistics."""
96
+ async with self._lock:
97
+ return {
98
+ "requests_per_minute": self.requests_per_minute,
99
+ "tokens_per_minute": self.tokens_per_minute,
100
+ "active_keys": len(self._request_buckets),
101
+ }
headroom/proxy/request_logger.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Request logger for the Headroom proxy.
2
+
3
+ Logs requests to an in-memory deque and optionally to a JSONL file.
4
+
5
+ Extracted from server.py for maintainability.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from collections import deque
13
+ from dataclasses import asdict
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from ..memory.tracker import ComponentStats
19
+
20
+ from headroom.proxy.models import RequestLog
21
+
22
+
23
+ class RequestLogger:
24
+ """Log requests to JSONL file.
25
+
26
+ Uses a deque with max 10,000 entries to prevent unbounded memory growth.
27
+ """
28
+
29
+ MAX_LOG_ENTRIES = 10_000
30
+
31
+ def __init__(self, log_file: str | None = None, log_full_messages: bool = False):
32
+ self.log_file = Path(log_file) if log_file else None
33
+ self.log_full_messages = log_full_messages
34
+ # Use deque with maxlen for automatic FIFO eviction
35
+ self._logs: deque[RequestLog] = deque(maxlen=self.MAX_LOG_ENTRIES)
36
+
37
+ if self.log_file:
38
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
39
+
40
+ def log(self, entry: RequestLog):
41
+ """Log a request. Oldest entries are automatically removed when limit reached."""
42
+ self._logs.append(entry)
43
+
44
+ if self.log_file:
45
+ with open(self.log_file, "a") as f:
46
+ log_dict = asdict(entry)
47
+ if not self.log_full_messages:
48
+ log_dict.pop("request_messages", None)
49
+ log_dict.pop("response_content", None)
50
+ f.write(json.dumps(log_dict) + "\n")
51
+
52
+ def get_recent(self, n: int = 100) -> list[dict]:
53
+ """Get recent log entries."""
54
+ # Convert deque to list for slicing (deque doesn't support slicing)
55
+ entries = list(self._logs)[-n:]
56
+ return [
57
+ {
58
+ k: v
59
+ for k, v in asdict(e).items()
60
+ if k not in ("request_messages", "response_content")
61
+ }
62
+ for e in entries
63
+ ]
64
+
65
+ def stats(self) -> dict:
66
+ """Get logging statistics."""
67
+ return {
68
+ "total_logged": len(self._logs),
69
+ "log_file": str(self.log_file) if self.log_file else None,
70
+ }
71
+
72
+ def get_memory_stats(self) -> ComponentStats:
73
+ """Get memory statistics for the MemoryTracker.
74
+
75
+ Returns:
76
+ ComponentStats with current memory usage.
77
+ """
78
+ from ..memory.tracker import ComponentStats
79
+
80
+ # Calculate size
81
+ size_bytes = sys.getsizeof(self._logs)
82
+
83
+ for log_entry in self._logs:
84
+ size_bytes += sys.getsizeof(log_entry)
85
+ # Add string fields
86
+ if log_entry.request_id:
87
+ size_bytes += len(log_entry.request_id)
88
+ if log_entry.provider:
89
+ size_bytes += len(log_entry.provider)
90
+ if log_entry.model:
91
+ size_bytes += len(log_entry.model)
92
+ if log_entry.error:
93
+ size_bytes += len(log_entry.error)
94
+ # Messages and response can be large
95
+ if log_entry.request_messages:
96
+ size_bytes += sys.getsizeof(log_entry.request_messages)
97
+ if log_entry.response_content:
98
+ size_bytes += len(log_entry.response_content)
99
+
100
+ return ComponentStats(
101
+ name="request_logger",
102
+ entry_count=len(self._logs),
103
+ size_bytes=size_bytes,
104
+ budget_bytes=None,
105
+ hits=0,
106
+ misses=0,
107
+ evictions=0,
108
+ )
headroom/proxy/semantic_cache.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Semantic cache for the Headroom proxy.
2
+
3
+ Simple semantic cache based on message content hash with LRU eviction.
4
+
5
+ Extracted from server.py for maintainability.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import hashlib
12
+ import json
13
+ import sys
14
+ from collections import OrderedDict
15
+ from datetime import datetime
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from ..memory.tracker import ComponentStats
20
+
21
+ from headroom.proxy.models import CacheEntry
22
+
23
+
24
+ class SemanticCache:
25
+ """Simple semantic cache based on message content hash.
26
+
27
+ Uses OrderedDict for O(1) LRU eviction instead of list with O(n) pop(0).
28
+ """
29
+
30
+ def __init__(self, max_entries: int = 1000, ttl_seconds: int = 3600):
31
+ self.max_entries = max_entries
32
+ self.ttl_seconds = ttl_seconds
33
+ # OrderedDict maintains insertion order and supports O(1) move_to_end/popitem
34
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
35
+ self._lock = asyncio.Lock()
36
+
37
+ def _compute_key(self, messages: list[dict], model: str) -> str:
38
+ """Compute cache key from messages and model."""
39
+ # Normalize messages for consistent hashing
40
+ normalized = json.dumps(
41
+ {
42
+ "model": model,
43
+ "messages": messages,
44
+ },
45
+ sort_keys=True,
46
+ )
47
+ return hashlib.sha256(normalized.encode()).hexdigest()[:32]
48
+
49
+ async def get(self, messages: list[dict], model: str) -> CacheEntry | None:
50
+ """Get cached response if exists and not expired."""
51
+ key = self._compute_key(messages, model)
52
+ async with self._lock:
53
+ entry = self._cache.get(key)
54
+
55
+ if entry is None:
56
+ return None
57
+
58
+ # Check expiration
59
+ age = (datetime.now() - entry.created_at).total_seconds()
60
+ if age > entry.ttl_seconds:
61
+ del self._cache[key]
62
+ return None
63
+
64
+ entry.hit_count += 1
65
+ # Move to end for LRU (O(1) operation)
66
+ self._cache.move_to_end(key)
67
+ return entry
68
+
69
+ async def set(
70
+ self,
71
+ messages: list[dict],
72
+ model: str,
73
+ response_body: bytes,
74
+ response_headers: dict[str, str],
75
+ tokens_saved: int = 0,
76
+ ):
77
+ """Cache a response."""
78
+ key = self._compute_key(messages, model)
79
+
80
+ async with self._lock:
81
+ # If key already exists, remove it first to update position
82
+ if key in self._cache:
83
+ del self._cache[key]
84
+
85
+ # Evict oldest entries if at capacity (LRU) - O(1) with popitem
86
+ while len(self._cache) >= self.max_entries:
87
+ self._cache.popitem(last=False) # Remove oldest (first) entry
88
+
89
+ self._cache[key] = CacheEntry(
90
+ response_body=response_body,
91
+ response_headers=response_headers,
92
+ created_at=datetime.now(),
93
+ ttl_seconds=self.ttl_seconds,
94
+ tokens_saved_per_hit=tokens_saved,
95
+ )
96
+
97
+ async def stats(self) -> dict:
98
+ """Get cache statistics."""
99
+ async with self._lock:
100
+ total_hits = sum(e.hit_count for e in self._cache.values())
101
+ return {
102
+ "entries": len(self._cache),
103
+ "max_entries": self.max_entries,
104
+ "total_hits": total_hits,
105
+ "ttl_seconds": self.ttl_seconds,
106
+ }
107
+
108
+ async def clear(self):
109
+ """Clear all cache entries."""
110
+ async with self._lock:
111
+ self._cache.clear()
112
+
113
+ def get_memory_stats(self) -> ComponentStats:
114
+ """Get memory statistics for the MemoryTracker.
115
+
116
+ Returns:
117
+ ComponentStats with current memory usage.
118
+ """
119
+ from ..memory.tracker import ComponentStats
120
+
121
+ # Calculate size - this is sync but we access _cache directly
122
+ # Note: This is a rough estimate, not perfectly accurate under async load
123
+ size_bytes = sys.getsizeof(self._cache)
124
+ total_hits = 0
125
+
126
+ for entry in self._cache.values():
127
+ size_bytes += sys.getsizeof(entry)
128
+ size_bytes += len(entry.response_body)
129
+ size_bytes += sys.getsizeof(entry.response_headers)
130
+ for k, v in entry.response_headers.items():
131
+ size_bytes += len(k) + len(v)
132
+ total_hits += entry.hit_count
133
+
134
+ return ComponentStats(
135
+ name="semantic_cache",
136
+ entry_count=len(self._cache),
137
+ size_bytes=size_bytes,
138
+ budget_bytes=None,
139
+ hits=total_hits,
140
+ misses=0, # Would need to track this separately
141
+ evictions=0, # Would need to track this separately
142
+ )
headroom/proxy/server.py CHANGED
@@ -25,22 +25,19 @@ from __future__ import annotations
25
 
26
  import argparse
27
  import asyncio
28
- import hashlib
29
  import json
30
  import logging
31
  import os
32
  import random
33
  import sys
34
  import time
35
- from collections import OrderedDict, defaultdict, deque
36
- from dataclasses import asdict
37
- from datetime import datetime, timedelta
38
  from pathlib import Path
39
  from typing import TYPE_CHECKING, Any, Literal
40
 
41
  if TYPE_CHECKING:
42
  from ..cache.compression_cache import CompressionCache
43
- from ..memory.tracker import ComponentStats, MemoryTracker
44
 
45
  import contextlib
46
 
@@ -88,11 +85,37 @@ from headroom.config import (
88
  )
89
  from headroom.dashboard import get_dashboard_html
90
  from headroom.providers import AnthropicProvider, OpenAIProvider
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  from headroom.proxy.memory_handler import MemoryConfig, MemoryHandler
92
 
93
  # Data models (extracted to headroom/proxy/models.py for maintainability)
94
  from headroom.proxy.models import CacheEntry, ProxyConfig, RateLimitState, RequestLog # noqa: F401
95
- from headroom.proxy.savings_tracker import SavingsTracker
 
 
 
96
  from headroom.telemetry import get_telemetry_collector
97
  from headroom.telemetry.toin import get_toin
98
  from headroom.tokenizers import get_tokenizer
@@ -111,33 +134,6 @@ from headroom.transforms import (
111
  )
112
  from headroom.utils import extract_user_query
113
 
114
- # Image compression (lazy-loaded to avoid heavy dependencies at startup)
115
- _image_compressor = None
116
-
117
-
118
- def _get_image_compressor():
119
- """Lazy load image compressor to avoid startup overhead."""
120
- global _image_compressor
121
- if _image_compressor is None:
122
- try:
123
- from headroom.image import ImageCompressor
124
-
125
- _image_compressor = ImageCompressor()
126
- logger.info("Image compression enabled (model: chopratejas/technique-router)")
127
- except ImportError as e:
128
- logger.warning(f"Image compression not available: {e}")
129
- _image_compressor = False # Mark as unavailable
130
- return _image_compressor if _image_compressor else None
131
-
132
-
133
- # Try to import LiteLLM for pricing
134
- try:
135
- import litellm
136
-
137
- LITELLM_AVAILABLE = True
138
- except ImportError:
139
- LITELLM_AVAILABLE = False
140
-
141
  logging.basicConfig(
142
  level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
143
  )
@@ -147,464 +143,13 @@ logger = logging.getLogger("headroom.proxy")
147
  _HEADROOM_LOG_DIR = Path.home() / ".headroom" / "logs"
148
 
149
 
150
- def _setup_file_logging() -> None:
151
- """Add a RotatingFileHandler to the headroom root logger.
152
-
153
- Writes to ~/.headroom/logs/proxy.log with automatic rotation:
154
- - Rotates at 10 MB
155
- - Keeps 5 backups (~50 MB max)
156
- """
157
- from logging.handlers import RotatingFileHandler
158
-
159
- try:
160
- _HEADROOM_LOG_DIR.mkdir(parents=True, exist_ok=True)
161
- log_path = _HEADROOM_LOG_DIR / "proxy.log"
162
- handler = RotatingFileHandler(
163
- log_path,
164
- maxBytes=10 * 1024 * 1024, # 10 MB
165
- backupCount=5,
166
- encoding="utf-8",
167
- )
168
- handler.setLevel(logging.INFO)
169
- handler.setFormatter(
170
- logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
171
- )
172
- # Attach to the headroom root logger so all sub-loggers are captured
173
- logging.getLogger("headroom").addHandler(handler)
174
- except OSError:
175
- # Non-fatal: can't write logs (read-only fs, permissions, etc.)
176
- pass
177
-
178
-
179
  _setup_file_logging()
180
 
181
 
182
- def _summarize_transforms(transforms: list[str]) -> str:
183
- """Collapse repeated transforms into counted summary.
184
-
185
- e.g. ['router:excluded:tool', 'router:excluded:tool', 'read_lifecycle:stale']
186
- → 'router:excluded:tool*2 read_lifecycle:stale'
187
- """
188
- if not transforms:
189
- return "none"
190
- counts: dict[str, int] = {}
191
- for t in transforms:
192
- counts[t] = counts.get(t, 0) + 1
193
- parts = [f"{k}*{v}" if v > 1 else k for k, v in counts.items()]
194
- return " ".join(parts)
195
-
196
-
197
- # Provider-specific cache discount multipliers (what fraction of input price)
198
- # Used to calculate dollar savings from prefix caching
199
- _CACHE_ECONOMICS = {
200
- "anthropic": {
201
- "read_multiplier": 0.1,
202
- "write_multiplier": 1.25,
203
- "label": "Explicit breakpoints, 5-min TTL",
204
- },
205
- "openai": {
206
- "read_multiplier": 0.5,
207
- "write_multiplier": 1.0,
208
- "label": "Automatic, no TTL control",
209
- },
210
- "gemini": {
211
- "read_multiplier": 0.1,
212
- "write_multiplier": 1.0,
213
- "label": "Explicit cachedContent, configurable TTL",
214
- },
215
- "bedrock": {
216
- "read_multiplier": 0.1,
217
- "write_multiplier": 1.25,
218
- "label": "Same as Anthropic (Bedrock)",
219
- },
220
- }
221
-
222
-
223
- def _get_rtk_stats() -> dict[str, Any] | None:
224
- """Get rtk (Rust Token Killer) savings stats if rtk is installed.
225
-
226
- Reads from rtk's tracking database via `rtk gain --format json`.
227
- Returns None if rtk is not installed.
228
- """
229
- import shutil
230
- import subprocess as _sp
231
-
232
- rtk_bin = shutil.which("rtk")
233
- if not rtk_bin:
234
- # Check headroom-managed install
235
- rtk_managed = Path.home() / ".headroom" / "bin" / "rtk"
236
- if rtk_managed.exists():
237
- rtk_bin = str(rtk_managed)
238
- else:
239
- return None
240
-
241
- try:
242
- result = _sp.run(
243
- [rtk_bin, "gain", "--format", "json"],
244
- capture_output=True,
245
- text=True,
246
- timeout=5,
247
- )
248
- if result.returncode == 0 and result.stdout.strip():
249
- data = json.loads(result.stdout)
250
- summary = data.get("summary", {})
251
- return {
252
- "installed": True,
253
- "total_commands": summary.get("total_commands", 0),
254
- "tokens_saved": summary.get("total_saved", 0),
255
- "avg_savings_pct": summary.get("avg_savings_pct", 0.0),
256
- }
257
- except Exception:
258
- pass
259
-
260
- return {"installed": True, "total_commands": 0, "tokens_saved": 0, "avg_savings_pct": 0.0}
261
-
262
-
263
- def _build_prefix_cache_stats(
264
- metrics: PrometheusMetrics,
265
- cost_tracker: CostTracker | None,
266
- ) -> dict:
267
- """Build provider-aware prefix cache statistics for the dashboard."""
268
- by_provider = {}
269
- totals = {
270
- "cache_read_tokens": 0,
271
- "cache_write_tokens": 0,
272
- "requests": 0,
273
- "hit_requests": 0,
274
- "bust_count": 0,
275
- "bust_write_tokens": 0,
276
- "savings_usd": 0.0,
277
- "write_premium_usd": 0.0,
278
- }
279
-
280
- for provider, pc in metrics.cache_by_provider.items():
281
- if pc["requests"] == 0:
282
- continue
283
-
284
- econ = _CACHE_ECONOMICS.get(provider, _CACHE_ECONOMICS["anthropic"])
285
- read_mult: float = econ["read_multiplier"] # type: ignore[assignment]
286
- write_mult: float = econ["write_multiplier"] # type: ignore[assignment]
287
-
288
- # Get the base input price per token for the most-used model on this provider
289
- input_price_per_token = None
290
- if cost_tracker:
291
- for model_name in cost_tracker._tokens_sent_by_model:
292
- # Match model to provider
293
- _openai_prefixes = ("gpt", "o1", "o3", "o4")
294
- is_match = (
295
- (provider == "anthropic" and "claude" in model_name)
296
- or (provider == "openai" and any(p in model_name for p in _openai_prefixes))
297
- or (provider == "gemini" and "gemini" in model_name)
298
- or (provider == "bedrock" and "claude" in model_name)
299
- )
300
- if is_match:
301
- price_per_1m = cost_tracker._get_list_price(model_name)
302
- if price_per_1m:
303
- input_price_per_token = price_per_1m / 1_000_000
304
- break
305
-
306
- # Calculate savings:
307
- # Cache reads save (1.0 - read_mult) per token vs uncached input price.
308
- # Cache write premium is NOT deducted — it's baseline cost that the
309
- # client (e.g. Claude Code) pays regardless of Headroom. We track it
310
- # for observability but don't penalise our savings number.
311
- read_tokens: int = pc["cache_read_tokens"] # type: ignore[assignment]
312
- write_tokens: int = pc["cache_write_tokens"] # type: ignore[assignment]
313
- savings_usd = 0.0
314
- write_premium_usd = 0.0
315
-
316
- if input_price_per_token:
317
- # Savings from reads: tokens * price * (1.0 - read_multiplier)
318
- savings_usd = read_tokens * input_price_per_token * (1.0 - read_mult)
319
- # Write premium (observability only — not subtracted from savings)
320
- if write_mult > 1.0:
321
- write_premium_usd = write_tokens * input_price_per_token * (write_mult - 1.0)
322
-
323
- hit_rate = round(pc["hit_requests"] / pc["requests"] * 100, 1) if pc["requests"] > 0 else 0
324
-
325
- provider_stats = {
326
- "cache_read_tokens": read_tokens,
327
- "cache_write_tokens": write_tokens,
328
- "requests": pc["requests"],
329
- "hit_requests": pc["hit_requests"],
330
- "hit_rate": hit_rate,
331
- "bust_count": pc["bust_count"],
332
- "bust_write_tokens": pc["bust_write_tokens"],
333
- "read_discount": f"{(1.0 - read_mult) * 100:.0f}%",
334
- "write_premium": f"{(write_mult - 1.0) * 100:.0f}%" if write_mult > 1.0 else "none",
335
- "savings_usd": round(savings_usd, 4),
336
- "write_premium_usd": round(write_premium_usd, 4),
337
- "net_savings_usd": round(savings_usd, 4),
338
- "label": str(econ["label"]),
339
- }
340
- by_provider[provider] = provider_stats
341
-
342
- # Accumulate totals
343
- totals["cache_read_tokens"] += read_tokens
344
- totals["cache_write_tokens"] += write_tokens
345
- totals["requests"] += pc["requests"]
346
- totals["hit_requests"] += pc["hit_requests"]
347
- totals["bust_count"] += pc["bust_count"]
348
- totals["bust_write_tokens"] += pc["bust_write_tokens"]
349
- totals["savings_usd"] += savings_usd
350
- totals["write_premium_usd"] += write_premium_usd
351
-
352
- totals["net_savings_usd"] = round(totals["savings_usd"], 4)
353
- totals["savings_usd"] = round(totals["savings_usd"], 4)
354
- totals["write_premium_usd"] = round(totals["write_premium_usd"], 4)
355
- totals["hit_rate"] = (
356
- round(totals["hit_requests"] / totals["requests"] * 100, 1) if totals["requests"] > 0 else 0
357
- )
358
-
359
- return {
360
- "by_provider": by_provider,
361
- "totals": totals,
362
- "prefix_freeze": {
363
- "busts_avoided": metrics.prefix_freeze_busts_avoided,
364
- "tokens_preserved": metrics.prefix_freeze_tokens_preserved,
365
- "compression_foregone_tokens": metrics.prefix_freeze_compression_foregone,
366
- "net_benefit_tokens": (
367
- metrics.prefix_freeze_tokens_preserved - metrics.prefix_freeze_compression_foregone
368
- ),
369
- },
370
- "attribution": (
371
- "Prefix caching is performed by the LLM provider (Anthropic, OpenAI). "
372
- "Headroom reports cache stats as observed from API responses. "
373
- "CacheAligner and prefix freeze improve cache hit rates by stabilizing "
374
- "the message prefix, but baseline caching happens without Headroom."
375
- ),
376
- }
377
-
378
-
379
- def _merge_cost_stats(
380
- cost_stats: dict | None,
381
- cache_stats: dict,
382
- cli_tokens_avoided: int = 0,
383
- ) -> dict | None:
384
- """Merge compression, cache, and CLI savings into cost stats.
385
-
386
- Each savings layer is reported separately with its own scope:
387
- - savings_usd: compression savings at model list price (monotonic)
388
- - cache_savings_usd: prefix cache discount from provider (separate)
389
- - cli_tokens_avoided: tokens filtered by rtk (token count only, no $ estimate)
390
-
391
- The hero metric (savings_usd) is ONLY compression savings priced at
392
- the model's published input rate. Cache and CLI are shown separately.
393
- This avoids the non-monotonic moving-average repricing bug (#83).
394
- """
395
- if cost_stats is None:
396
- return None
397
-
398
- cache_net = cache_stats.get("totals", {}).get("net_savings_usd", 0.0)
399
- compression_savings = cost_stats.get("savings_usd", 0.0)
400
-
401
- return {
402
- **cost_stats,
403
- "savings_usd": round(compression_savings, 4),
404
- "compression_savings_usd": round(compression_savings, 4),
405
- "cache_savings_usd": round(cache_net, 4),
406
- "cli_tokens_avoided": cli_tokens_avoided,
407
- }
408
-
409
-
410
- def _build_session_summary(
411
- proxy: HeadroomProxy,
412
- metrics: Any,
413
- prefix_cache_stats: dict,
414
- cli_tokens_avoided: int,
415
- total_tokens_before: int,
416
- ) -> dict[str, Any]:
417
- """Build a human-readable session summary from metrics and request logs.
418
-
419
- This is the headline view users see first in /stats — designed to answer
420
- "is Headroom working?" at a glance.
421
- """
422
- # Analyze per-request compression from the logger
423
- compressed_requests: list[dict] = []
424
- uncompressed_reasons: dict[str, int] = {
425
- "prefix_frozen": 0,
426
- "too_small": 0,
427
- "passthrough": 0,
428
- "no_compressible_content": 0,
429
- }
430
-
431
- if proxy.logger:
432
- for entry in proxy.logger._logs:
433
- if entry.model and "count_tokens" in entry.model:
434
- uncompressed_reasons["passthrough"] += 1
435
- continue
436
- if entry.tokens_saved > 0 and entry.savings_percent > 0:
437
- compressed_requests.append(
438
- {
439
- "savings_pct": round(entry.savings_percent, 1),
440
- "tokens_saved": entry.tokens_saved,
441
- "original": entry.input_tokens_original,
442
- "optimized": entry.input_tokens_optimized,
443
- }
444
- )
445
- elif entry.input_tokens_original > 0:
446
- # Categorize why it wasn't compressed
447
- transforms = entry.transforms_applied or []
448
- if not transforms:
449
- # Pipeline returned unchanged — likely all frozen
450
- uncompressed_reasons["prefix_frozen"] += 1
451
- elif all("excluded" in t or "protected" in t for t in transforms):
452
- uncompressed_reasons["no_compressible_content"] += 1
453
- elif entry.input_tokens_original < 500:
454
- uncompressed_reasons["too_small"] += 1
455
- else:
456
- uncompressed_reasons["prefix_frozen"] += 1
457
-
458
- # Compute compression stats for requests that DID compress
459
- avg_compression = 0.0
460
- best_compression = 0.0
461
- best_detail = ""
462
- if compressed_requests:
463
- avg_compression = round(
464
- sum(r["savings_pct"] for r in compressed_requests) / len(compressed_requests),
465
- 1,
466
- )
467
- best = max(compressed_requests, key=lambda r: r["savings_pct"])
468
- best_compression = best["savings_pct"]
469
- best_detail = f"{best['original']:,} → {best['optimized']:,} tokens"
470
-
471
- # Cost summary — savings_usd is compression savings at model list price (monotonic)
472
- cost_stats = proxy.cost_tracker.stats() if proxy.cost_tracker else {}
473
- cost_with = cost_stats.get("cost_with_headroom_usd", 0.0)
474
- compression_savings = cost_stats.get("savings_usd", 0.0)
475
- cache_net = prefix_cache_stats.get("totals", {}).get("net_savings_usd", 0.0)
476
- total_saved_usd = round(compression_savings, 2)
477
- cost_without = cost_with + compression_savings
478
- savings_pct_cost = round(total_saved_usd / cost_without * 100, 1) if cost_without > 0 else 0.0
479
-
480
- # Primary models used
481
- models = dict(metrics.requests_by_model)
482
- primary_model = max(models, key=lambda k: models[k]) if models else "unknown"
483
- api_requests = sum(v for k, v in models.items() if "count_tokens" not in k)
484
-
485
- # Build the summary
486
- summary: dict[str, Any] = {
487
- "mode": proxy.config.mode,
488
- "api_requests": api_requests,
489
- "primary_model": primary_model,
490
- "compression": {
491
- "requests_compressed": len(compressed_requests),
492
- "avg_compression_pct": avg_compression,
493
- "best_compression_pct": best_compression,
494
- "best_detail": best_detail,
495
- "total_tokens_removed": metrics.tokens_saved_total,
496
- },
497
- "uncompressed_requests": {k: v for k, v in uncompressed_reasons.items() if v > 0},
498
- "cost": {
499
- "without_headroom_usd": round(cost_without, 2),
500
- "with_headroom_usd": round(cost_with, 2),
501
- "total_saved_usd": total_saved_usd,
502
- "savings_pct": savings_pct_cost,
503
- "breakdown": {
504
- "cache_savings_usd": round(cache_net, 2),
505
- "compression_savings_usd": round(compression_savings, 2),
506
- },
507
- },
508
- }
509
-
510
- # Add tip if token_headroom mode would help
511
- if proxy.config.mode == "cost_savings" and uncompressed_reasons["prefix_frozen"] > 10:
512
- summary["tip"] = (
513
- "Most requests are prefix-frozen. Set HEADROOM_MODE=token_headroom "
514
- "to compress frozen messages and extend your session by ~25-35%."
515
- )
516
-
517
- return summary
518
-
519
-
520
- # Maximum request body size (100MB - increased to support image-heavy requests)
521
- MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
522
-
523
- # Maximum SSE buffer size (10MB - prevents memory exhaustion from malformed streams)
524
- MAX_SSE_BUFFER_SIZE = 10 * 1024 * 1024
525
-
526
- # Maximum message array length (prevents DoS from deeply nested payloads)
527
- MAX_MESSAGE_ARRAY_LENGTH = 10000
528
-
529
-
530
- async def _read_request_json(request: Request) -> dict[str, Any]:
531
- """Read and parse JSON from a request, handling compressed bodies.
532
-
533
- Clients like OpenAI Codex may send zstd, gzip, or deflate-compressed
534
- request bodies. Starlette's ``request.json()`` does not decompress
535
- automatically, causing a UnicodeDecodeError on compressed bytes.
536
-
537
- This helper inspects ``Content-Encoding``, decompresses if needed,
538
- then JSON-decodes the result. It raises ``ValueError`` on any
539
- decompression or parse failure so callers can return a clean 400.
540
- """
541
- encoding = (request.headers.get("content-encoding") or "").lower().strip()
542
- raw = await request.body()
543
-
544
- if encoding in ("zstd", "zstandard"):
545
- try:
546
- import zstandard
547
-
548
- dctx = zstandard.ZstdDecompressor()
549
- # Use stream_reader for streaming zstd frames (no content size in header).
550
- # Plain decompress() fails when the frame header omits the size, which
551
- # is common with clients like OpenAI Codex.
552
- reader = dctx.stream_reader(raw)
553
- raw = reader.read()
554
- reader.close()
555
- except ImportError:
556
- raise ValueError(
557
- "Request body is zstd-compressed but the 'zstandard' package is not installed. "
558
- "Install it with: pip install zstandard"
559
- ) from None
560
- except Exception as exc:
561
- raise ValueError(f"Failed to decompress zstd request body: {exc}") from exc
562
- elif encoding == "gzip":
563
- import gzip as _gzip
564
-
565
- try:
566
- raw = _gzip.decompress(raw)
567
- except Exception as exc:
568
- raise ValueError(f"Failed to decompress gzip request body: {exc}") from exc
569
- elif encoding == "deflate":
570
- import zlib
571
-
572
- try:
573
- raw = zlib.decompress(raw)
574
- except Exception as exc:
575
- raise ValueError(f"Failed to decompress deflate request body: {exc}") from exc
576
- elif encoding == "br":
577
- try:
578
- import brotli
579
-
580
- raw = brotli.decompress(raw)
581
- except ImportError:
582
- raise ValueError(
583
- "Request body is brotli-compressed but the 'brotli' package is not installed."
584
- ) from None
585
- except Exception as exc:
586
- raise ValueError(f"Failed to decompress brotli request body: {exc}") from exc
587
- elif encoding and encoding != "identity":
588
- raise ValueError(f"Unsupported Content-Encoding: {encoding}")
589
-
590
- # Decode and parse JSON
591
- try:
592
- text = raw.decode("utf-8")
593
- except UnicodeDecodeError as exc:
594
- raise ValueError(f"Request body is not valid UTF-8 (possibly compressed?): {exc}") from exc
595
-
596
- result: dict[str, Any] = json.loads(text)
597
- return result
598
-
599
-
600
- # Maximum compression cache sessions (prevents unbounded memory growth)
601
- MAX_COMPRESSION_CACHE_SESSIONS = 500
602
-
603
  # Maximum rate limiter buckets (prevents DoS via spoofed API keys)
604
  MAX_RATE_LIMITER_BUCKETS = 1000
605
 
606
  # Compression pipeline timeout in seconds
607
- COMPRESSION_TIMEOUT_SECONDS = 30
608
 
609
 
610
  # =============================================================================
@@ -612,917 +157,6 @@ COMPRESSION_TIMEOUT_SECONDS = 30
612
  # =============================================================================
613
 
614
 
615
- class SemanticCache:
616
- """Simple semantic cache based on message content hash.
617
-
618
- Uses OrderedDict for O(1) LRU eviction instead of list with O(n) pop(0).
619
- """
620
-
621
- def __init__(self, max_entries: int = 1000, ttl_seconds: int = 3600):
622
- self.max_entries = max_entries
623
- self.ttl_seconds = ttl_seconds
624
- # OrderedDict maintains insertion order and supports O(1) move_to_end/popitem
625
- self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
626
- self._lock = asyncio.Lock()
627
-
628
- def _compute_key(self, messages: list[dict], model: str) -> str:
629
- """Compute cache key from messages and model."""
630
- # Normalize messages for consistent hashing
631
- normalized = json.dumps(
632
- {
633
- "model": model,
634
- "messages": messages,
635
- },
636
- sort_keys=True,
637
- )
638
- return hashlib.sha256(normalized.encode()).hexdigest()[:32]
639
-
640
- async def get(self, messages: list[dict], model: str) -> CacheEntry | None:
641
- """Get cached response if exists and not expired."""
642
- key = self._compute_key(messages, model)
643
- async with self._lock:
644
- entry = self._cache.get(key)
645
-
646
- if entry is None:
647
- return None
648
-
649
- # Check expiration
650
- age = (datetime.now() - entry.created_at).total_seconds()
651
- if age > entry.ttl_seconds:
652
- del self._cache[key]
653
- return None
654
-
655
- entry.hit_count += 1
656
- # Move to end for LRU (O(1) operation)
657
- self._cache.move_to_end(key)
658
- return entry
659
-
660
- async def set(
661
- self,
662
- messages: list[dict],
663
- model: str,
664
- response_body: bytes,
665
- response_headers: dict[str, str],
666
- tokens_saved: int = 0,
667
- ):
668
- """Cache a response."""
669
- key = self._compute_key(messages, model)
670
-
671
- async with self._lock:
672
- # If key already exists, remove it first to update position
673
- if key in self._cache:
674
- del self._cache[key]
675
-
676
- # Evict oldest entries if at capacity (LRU) - O(1) with popitem
677
- while len(self._cache) >= self.max_entries:
678
- self._cache.popitem(last=False) # Remove oldest (first) entry
679
-
680
- self._cache[key] = CacheEntry(
681
- response_body=response_body,
682
- response_headers=response_headers,
683
- created_at=datetime.now(),
684
- ttl_seconds=self.ttl_seconds,
685
- tokens_saved_per_hit=tokens_saved,
686
- )
687
-
688
- async def stats(self) -> dict:
689
- """Get cache statistics."""
690
- async with self._lock:
691
- total_hits = sum(e.hit_count for e in self._cache.values())
692
- return {
693
- "entries": len(self._cache),
694
- "max_entries": self.max_entries,
695
- "total_hits": total_hits,
696
- "ttl_seconds": self.ttl_seconds,
697
- }
698
-
699
- async def clear(self):
700
- """Clear all cache entries."""
701
- async with self._lock:
702
- self._cache.clear()
703
-
704
- def get_memory_stats(self) -> ComponentStats:
705
- """Get memory statistics for the MemoryTracker.
706
-
707
- Returns:
708
- ComponentStats with current memory usage.
709
- """
710
- from ..memory.tracker import ComponentStats
711
-
712
- # Calculate size - this is sync but we access _cache directly
713
- # Note: This is a rough estimate, not perfectly accurate under async load
714
- size_bytes = sys.getsizeof(self._cache)
715
- total_hits = 0
716
-
717
- for entry in self._cache.values():
718
- size_bytes += sys.getsizeof(entry)
719
- size_bytes += len(entry.response_body)
720
- size_bytes += sys.getsizeof(entry.response_headers)
721
- for k, v in entry.response_headers.items():
722
- size_bytes += len(k) + len(v)
723
- total_hits += entry.hit_count
724
-
725
- return ComponentStats(
726
- name="semantic_cache",
727
- entry_count=len(self._cache),
728
- size_bytes=size_bytes,
729
- budget_bytes=None,
730
- hits=total_hits,
731
- misses=0, # Would need to track this separately
732
- evictions=0, # Would need to track this separately
733
- )
734
-
735
-
736
- # =============================================================================
737
- # Rate Limiting
738
- # =============================================================================
739
-
740
-
741
- class TokenBucketRateLimiter:
742
- """Token bucket rate limiter for requests and tokens."""
743
-
744
- def __init__(
745
- self,
746
- requests_per_minute: int = 60,
747
- tokens_per_minute: int = 100000,
748
- ):
749
- self.requests_per_minute = requests_per_minute
750
- self.tokens_per_minute = tokens_per_minute
751
-
752
- # Per-key buckets (key = API key or IP)
753
- self._request_buckets: dict[str, RateLimitState] = defaultdict(
754
- lambda: RateLimitState(tokens=requests_per_minute, last_update=time.time())
755
- )
756
- self._token_buckets: dict[str, RateLimitState] = defaultdict(
757
- lambda: RateLimitState(tokens=tokens_per_minute, last_update=time.time())
758
- )
759
- self._lock = asyncio.Lock()
760
-
761
- async def _cleanup_stale_buckets(self) -> None:
762
- """Remove buckets that haven't been used in the last 10 minutes."""
763
- now = time.time()
764
- stale_threshold = now - 600 # 10 minutes
765
- stale_keys = [
766
- k for k, v in self._request_buckets.items() if v.last_update < stale_threshold
767
- ]
768
- for k in stale_keys:
769
- del self._request_buckets[k]
770
- self._token_buckets.pop(k, None)
771
- if stale_keys:
772
- logger.debug(f"Cleaned up {len(stale_keys)} stale rate limiter buckets")
773
-
774
- def _refill(self, state: RateLimitState, rate_per_minute: float) -> float:
775
- """Refill bucket based on elapsed time."""
776
- now = time.time()
777
- elapsed = now - state.last_update
778
- refill = elapsed * (rate_per_minute / 60.0)
779
- state.tokens = min(rate_per_minute, state.tokens + refill)
780
- state.last_update = now
781
- return state.tokens
782
-
783
- async def check_request(self, key: str = "default") -> tuple[bool, float]:
784
- """Check if request is allowed. Returns (allowed, wait_seconds)."""
785
- async with self._lock:
786
- # Prevent unbounded bucket growth from spoofed keys
787
- if len(self._request_buckets) > MAX_RATE_LIMITER_BUCKETS:
788
- await self._cleanup_stale_buckets()
789
- state = self._request_buckets[key]
790
- available = self._refill(state, self.requests_per_minute)
791
-
792
- if available >= 1:
793
- state.tokens -= 1
794
- return True, 0
795
-
796
- wait_seconds = (1 - available) * (60.0 / self.requests_per_minute)
797
- return False, wait_seconds
798
-
799
- async def check_tokens(self, key: str, token_count: int) -> tuple[bool, float]:
800
- """Check if token usage is allowed."""
801
- async with self._lock:
802
- state = self._token_buckets[key]
803
- available = self._refill(state, self.tokens_per_minute)
804
-
805
- if available >= token_count:
806
- state.tokens -= token_count
807
- return True, 0
808
-
809
- wait_seconds = (token_count - available) * (60.0 / self.tokens_per_minute)
810
- return False, wait_seconds
811
-
812
- async def stats(self) -> dict:
813
- """Get rate limiter statistics."""
814
- async with self._lock:
815
- return {
816
- "requests_per_minute": self.requests_per_minute,
817
- "tokens_per_minute": self.tokens_per_minute,
818
- "active_keys": len(self._request_buckets),
819
- }
820
-
821
-
822
- # =============================================================================
823
- # Cost Tracking
824
- # =============================================================================
825
-
826
-
827
- class CostTracker:
828
- """Track costs and enforce budgets.
829
-
830
- Cost history is automatically pruned to prevent unbounded memory growth:
831
- - Entries older than 24 hours are removed
832
- - Maximum of 100,000 entries are kept
833
-
834
- Uses LiteLLM's community-maintained pricing database for accurate costs.
835
- See: https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json
836
- """
837
-
838
- MAX_COST_ENTRIES = 100_000
839
- COST_RETENTION_HOURS = 24
840
-
841
- def __init__(self, budget_limit_usd: float | None = None, budget_period: str = "daily"):
842
- self.budget_limit_usd = budget_limit_usd
843
- self.budget_period = budget_period
844
-
845
- # Cost tracking - using deque for efficient left-side removal
846
- self._costs: deque[tuple[datetime, float]] = deque(maxlen=self.MAX_COST_ENTRIES)
847
- self._last_prune_time: datetime = datetime.now()
848
-
849
- # Token savings per model (exact, no dollar estimation)
850
- self._tokens_saved_by_model: dict[str, int] = {}
851
- self._tokens_sent_by_model: dict[str, int] = {}
852
- self._requests_by_model: dict[str, int] = {}
853
-
854
- # API-reported cache breakdown per model (for accurate cost calculation)
855
- self._api_cache_read_by_model: dict[str, int] = {}
856
- self._api_cache_write_by_model: dict[str, int] = {}
857
- self._api_uncached_by_model: dict[str, int] = {}
858
-
859
- # Cache resolved model names to avoid repeated litellm lookups.
860
- # This is critical: litellm.cost_per_token() is synchronous and can block
861
- # the async event loop if it triggers I/O (lazy model info download).
862
- _resolved_model_cache: dict[str, str] = {}
863
-
864
- @classmethod
865
- def _resolve_litellm_model(cls, model: str) -> str:
866
- """Resolve model name to one LiteLLM recognizes, adding provider prefix if needed.
867
-
868
- Results are cached per model name to avoid blocking the event loop
869
- with repeated synchronous litellm lookups.
870
- """
871
- if model in cls._resolved_model_cache:
872
- return cls._resolved_model_cache[model]
873
-
874
- resolved = cls._resolve_litellm_model_uncached(model)
875
- cls._resolved_model_cache[model] = resolved
876
- return resolved
877
-
878
- @staticmethod
879
- def _resolve_litellm_model_uncached(model: str) -> str:
880
- """Uncached resolution — called once per unique model name."""
881
- if not LITELLM_AVAILABLE:
882
- return model
883
-
884
- # Try as-is first
885
- try:
886
- litellm.cost_per_token(model=model, prompt_tokens=1, completion_tokens=0)
887
- return model
888
- except Exception:
889
- pass
890
-
891
- # Try with provider prefix
892
- prefixes = {
893
- "claude-": "anthropic/",
894
- "gpt-": "openai/",
895
- "o1-": "openai/",
896
- "o3-": "openai/",
897
- "o4-": "openai/",
898
- "gemini-": "google/",
899
- }
900
- for pattern, prefix in prefixes.items():
901
- if model.startswith(pattern):
902
- prefixed = f"{prefix}{model}"
903
- try:
904
- litellm.cost_per_token(model=prefixed, prompt_tokens=1, completion_tokens=0)
905
- return prefixed
906
- except Exception:
907
- break
908
-
909
- return model
910
-
911
- def estimate_cost(
912
- self,
913
- model: str,
914
- input_tokens: int,
915
- output_tokens: int,
916
- cache_read_tokens: int = 0,
917
- cache_write_tokens: int = 0,
918
- ) -> float | None:
919
- """Estimate cost in USD using LiteLLM's pricing database.
920
-
921
- LiteLLM natively handles cache_read and cache_creation pricing
922
- for all providers (Anthropic, OpenAI, Google, etc.) in a single call.
923
-
924
- Args:
925
- model: Model name for pricing lookup
926
- input_tokens: Non-cached input tokens (excludes cache_read)
927
- output_tokens: Output tokens
928
- cache_read_tokens: Tokens served from cache (~10% of input rate)
929
- cache_write_tokens: Tokens written to cache (~125% of input rate)
930
- """
931
- if not LITELLM_AVAILABLE:
932
- logger.warning("LiteLLM not available - cannot calculate costs")
933
- return None
934
-
935
- try:
936
- resolved_model = self._resolve_litellm_model(model)
937
-
938
- # litellm.cost_per_token handles all token types natively:
939
- # prompt_tokens at input rate, cache_read at ~10%, cache_creation at ~125%
940
- input_cost, output_cost = litellm.cost_per_token(
941
- model=resolved_model,
942
- prompt_tokens=input_tokens,
943
- completion_tokens=output_tokens,
944
- cache_read_input_tokens=cache_read_tokens,
945
- cache_creation_input_tokens=cache_write_tokens,
946
- )
947
-
948
- total_cost = input_cost + output_cost
949
- return float(total_cost) if total_cost > 0 else None
950
-
951
- except Exception as e:
952
- logger.warning(f"Failed to get pricing for model {model}: {e}")
953
- return None
954
-
955
- def _prune_old_costs(self):
956
- """Remove cost entries older than retention period.
957
-
958
- Called periodically (every 5 minutes) to prevent unbounded memory growth.
959
- The deque maxlen provides a hard cap, but time-based pruning keeps
960
- memory usage proportional to actual traffic patterns.
961
- """
962
- now = datetime.now()
963
- # Only prune every 5 minutes to avoid overhead
964
- if (now - self._last_prune_time).total_seconds() < 300:
965
- return
966
-
967
- self._last_prune_time = now
968
- cutoff = now - timedelta(hours=self.COST_RETENTION_HOURS)
969
-
970
- # Remove entries from the left (oldest) while they're older than cutoff
971
- while self._costs and self._costs[0][0] < cutoff:
972
- self._costs.popleft()
973
-
974
- def record_tokens(
975
- self,
976
- model: str,
977
- tokens_saved: int,
978
- tokens_sent: int,
979
- cache_read_tokens: int = 0,
980
- cache_write_tokens: int = 0,
981
- uncached_tokens: int = 0,
982
- ):
983
- """Record token counts per model.
984
-
985
- Args:
986
- model: Model name.
987
- tokens_saved: Tokens removed by compression (Headroom's count).
988
- tokens_sent: Compressed message tokens sent (Headroom's count).
989
- cache_read_tokens: Cache read tokens from API response usage.
990
- cache_write_tokens: Cache write tokens from API response usage.
991
- uncached_tokens: Non-cached input tokens from API response usage.
992
- """
993
- self._tokens_saved_by_model[model] = (
994
- self._tokens_saved_by_model.get(model, 0) + tokens_saved
995
- )
996
- self._tokens_sent_by_model[model] = self._tokens_sent_by_model.get(model, 0) + tokens_sent
997
- self._requests_by_model[model] = self._requests_by_model.get(model, 0) + 1
998
- self._api_cache_read_by_model[model] = (
999
- self._api_cache_read_by_model.get(model, 0) + cache_read_tokens
1000
- )
1001
- self._api_cache_write_by_model[model] = (
1002
- self._api_cache_write_by_model.get(model, 0) + cache_write_tokens
1003
- )
1004
- self._api_uncached_by_model[model] = (
1005
- self._api_uncached_by_model.get(model, 0) + uncached_tokens
1006
- )
1007
-
1008
- def get_period_cost(self) -> float:
1009
- """Get cost for current budget period."""
1010
- now = datetime.now()
1011
-
1012
- if self.budget_period == "hourly":
1013
- cutoff = now - timedelta(hours=1)
1014
- elif self.budget_period == "daily":
1015
- cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0)
1016
- else: # monthly
1017
- cutoff = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
1018
-
1019
- return sum(cost for ts, cost in self._costs if ts >= cutoff)
1020
-
1021
- def check_budget(self) -> tuple[bool, float]:
1022
- """Check if within budget. Returns (allowed, remaining)."""
1023
- if self.budget_limit_usd is None:
1024
- return True, float("inf")
1025
-
1026
- period_cost = self.get_period_cost()
1027
- remaining = self.budget_limit_usd - period_cost
1028
- return remaining > 0, max(0, remaining)
1029
-
1030
- def _get_list_price(self, model: str) -> float | None:
1031
- """Get list input price per 1M tokens for a model."""
1032
- if not LITELLM_AVAILABLE:
1033
- return None
1034
- try:
1035
- resolved = self._resolve_litellm_model(model)
1036
- info = litellm.model_cost.get(resolved, {})
1037
- cost_per_token = info.get("input_cost_per_token")
1038
- return cost_per_token * 1_000_000 if cost_per_token else None
1039
- except Exception:
1040
- return None
1041
-
1042
- def _get_cache_prices(self, model: str) -> tuple[float, float, float] | None:
1043
- """Get per-token prices for cache read, cache write, and uncached input.
1044
-
1045
- Returns (cache_read, cache_write, uncached) per-token costs, or None
1046
- if pricing is unavailable. Uses LiteLLM's native cache pricing data.
1047
- """
1048
- if not LITELLM_AVAILABLE:
1049
- return None
1050
- try:
1051
- resolved = self._resolve_litellm_model(model)
1052
- info = litellm.model_cost.get(resolved, {})
1053
- uncached = info.get("input_cost_per_token")
1054
- if not uncached:
1055
- return None
1056
- cache_read = info.get("cache_read_input_token_cost", uncached)
1057
- cache_write = info.get("cache_creation_input_token_cost", uncached)
1058
- return (cache_read, cache_write, uncached)
1059
- except Exception:
1060
- return None
1061
-
1062
- def stats(self) -> dict:
1063
- """Get token statistics per model."""
1064
- per_model = {}
1065
- total_saved = 0
1066
- for model in sorted(self._tokens_saved_by_model.keys()):
1067
- saved = self._tokens_saved_by_model[model]
1068
- sent = self._tokens_sent_by_model.get(model, 0)
1069
- reqs = self._requests_by_model.get(model, 0)
1070
- total_saved += saved
1071
- per_model[model] = {
1072
- "requests": reqs,
1073
- "tokens_saved": saved,
1074
- "tokens_sent": sent,
1075
- "reduction_pct": round(saved / (saved + sent) * 100, 1)
1076
- if (saved + sent) > 0
1077
- else 0,
1078
- }
1079
-
1080
- # Compute actual input cost using API-reported cache breakdown and
1081
- # LiteLLM's per-category pricing (cache reads discounted, writes at
1082
- # premium, uncached at list). Falls back to list price when cache
1083
- # data is unavailable.
1084
- cost_with_headroom = 0.0
1085
- total_billed_input_tokens = 0
1086
- total_input_tokens = 0
1087
- for model in self._tokens_saved_by_model:
1088
- saved = self._tokens_saved_by_model[model]
1089
- sent = self._tokens_sent_by_model.get(model, 0)
1090
- cr = self._api_cache_read_by_model.get(model, 0)
1091
- cw = self._api_cache_write_by_model.get(model, 0)
1092
- uncached = self._api_uncached_by_model.get(model, 0)
1093
- total_input_tokens += sent
1094
-
1095
- prices = self._get_cache_prices(model)
1096
- if prices:
1097
- cr_price, cw_price, uncached_price = prices
1098
- if cr + cw + uncached > 0:
1099
- # Use API's real cache breakdown with LiteLLM pricing
1100
- model_cost = cr * cr_price + cw * cw_price + uncached * uncached_price
1101
- billed_tokens = cr + cw + uncached
1102
- else:
1103
- # No cache data from API — fall back to list price
1104
- model_cost = sent * uncached_price
1105
- billed_tokens = sent
1106
- cost_with_headroom += model_cost
1107
- total_billed_input_tokens += billed_tokens
1108
-
1109
- # Compression savings: price saved tokens at the model's list input price.
1110
- # This is simple, monotonic, and transparent — each saved token is valued
1111
- # at the published $/token rate for its model. Not affected by cache mix.
1112
- savings_usd = 0.0
1113
- for model in self._tokens_saved_by_model:
1114
- saved = self._tokens_saved_by_model[model]
1115
- if saved <= 0:
1116
- continue
1117
- prices = self._get_cache_prices(model)
1118
- if prices:
1119
- _cr_price, _cw_price, uncached_price = prices
1120
- savings_usd += saved * uncached_price
1121
-
1122
- return {
1123
- "total_tokens_saved": total_saved,
1124
- "total_input_tokens": total_input_tokens,
1125
- "total_input_cost_usd": round(cost_with_headroom, 4),
1126
- "per_model": per_model,
1127
- "cost_with_headroom_usd": round(cost_with_headroom, 4),
1128
- "savings_usd": round(savings_usd, 4),
1129
- }
1130
-
1131
-
1132
- # =============================================================================
1133
- # Prometheus Metrics
1134
- # =============================================================================
1135
-
1136
-
1137
- class PrometheusMetrics:
1138
- """Prometheus-compatible metrics."""
1139
-
1140
- def __init__(
1141
- self,
1142
- savings_tracker: SavingsTracker | None = None,
1143
- cost_tracker: CostTracker | None = None,
1144
- ):
1145
- self.requests_total = 0
1146
- self.requests_by_provider: dict[str, int] = defaultdict(int)
1147
- self.requests_by_model: dict[str, int] = defaultdict(int)
1148
- self.requests_cached = 0
1149
- self.requests_rate_limited = 0
1150
- self.requests_failed = 0
1151
-
1152
- self.tokens_input_total = 0
1153
- self.tokens_output_total = 0
1154
- self.tokens_saved_total = 0
1155
-
1156
- self.latency_sum_ms = 0.0
1157
- self.latency_min_ms = float("inf")
1158
- self.latency_max_ms = 0.0
1159
- self.latency_count = 0
1160
-
1161
- # Headroom overhead (optimization time only, excludes LLM)
1162
- self.overhead_sum_ms = 0.0
1163
- self.overhead_min_ms = float("inf")
1164
- self.overhead_max_ms = 0.0
1165
- self.overhead_count = 0
1166
-
1167
- # Time to first byte (TTFB) from upstream — what the user actually feels
1168
- self.ttfb_sum_ms = 0.0
1169
- self.ttfb_min_ms = float("inf")
1170
- self.ttfb_max_ms = 0.0
1171
- self.ttfb_count = 0
1172
-
1173
- # Per-transform timing (name → cumulative ms, count)
1174
- self.transform_timing_sum: dict[str, float] = defaultdict(float)
1175
- self.transform_timing_count: dict[str, int] = defaultdict(int)
1176
- self.transform_timing_max: dict[str, float] = defaultdict(float)
1177
-
1178
- # Aggregate waste signals
1179
- self.waste_signals_total: dict[str, int] = defaultdict(int)
1180
-
1181
- # Provider-specific prefix cache tracking
1182
- # Each provider has different cache economics:
1183
- # Anthropic: cache_read=0.1x, cache_write=1.25x, explicit breakpoints
1184
- # OpenAI: cache_read=0.5x, no write penalty, automatic
1185
- # Google: cache_read=~0.1x, explicit cachedContent API, storage cost
1186
- # Bedrock: no cache metrics
1187
- self.cache_by_provider: dict[str, dict[str, int | float]] = defaultdict(
1188
- lambda: {
1189
- "cache_read_tokens": 0,
1190
- "cache_write_tokens": 0,
1191
- "requests": 0,
1192
- "hit_requests": 0, # requests with cache_read > 0
1193
- "bust_count": 0,
1194
- "bust_write_tokens": 0,
1195
- }
1196
- )
1197
- # Track per-model cache request count to distinguish cold starts from busts
1198
- self._cache_requests_by_model: dict[str, int] = defaultdict(int)
1199
-
1200
- # Prefix freeze stats (cache-aware compression)
1201
- self.prefix_freeze_busts_avoided: int = 0
1202
- self.prefix_freeze_tokens_preserved: int = 0
1203
- self.prefix_freeze_compression_foregone: int = 0
1204
-
1205
- # Cumulative savings history (timestamp → cumulative tokens saved)
1206
- self.savings_history: list[tuple[str, int]] = []
1207
- self.savings_tracker = savings_tracker or SavingsTracker()
1208
- self.cost_tracker = cost_tracker
1209
- tracker_lifetime = self.savings_tracker.snapshot()["lifetime"]
1210
- self._savings_tracker_input_tokens_offset = max(
1211
- int(tracker_lifetime.get("total_input_tokens", 0) or 0),
1212
- 0,
1213
- )
1214
- self._savings_tracker_input_cost_usd_offset = max(
1215
- float(tracker_lifetime.get("total_input_cost_usd", 0.0) or 0.0),
1216
- 0.0,
1217
- )
1218
-
1219
- self._lock = asyncio.Lock()
1220
-
1221
- def _current_savings_tracker_totals(self) -> tuple[int, float]:
1222
- total_input_tokens = self._savings_tracker_input_tokens_offset + self.tokens_input_total
1223
- total_input_cost_usd = self._savings_tracker_input_cost_usd_offset
1224
-
1225
- if self.cost_tracker is None:
1226
- return total_input_tokens, total_input_cost_usd
1227
-
1228
- try:
1229
- cost_stats = self.cost_tracker.stats()
1230
- except Exception:
1231
- logger.debug("Failed to read cost tracker totals for savings history", exc_info=True)
1232
- return total_input_tokens, total_input_cost_usd
1233
-
1234
- tracked_input_tokens = cost_stats.get("total_input_tokens")
1235
- tracked_input_cost_usd = cost_stats.get("total_input_cost_usd")
1236
-
1237
- if tracked_input_tokens is not None:
1238
- try:
1239
- total_input_tokens = self._savings_tracker_input_tokens_offset + max(
1240
- int(tracked_input_tokens),
1241
- 0,
1242
- )
1243
- except (TypeError, ValueError):
1244
- pass
1245
-
1246
- if tracked_input_cost_usd is not None:
1247
- try:
1248
- total_input_cost_usd = self._savings_tracker_input_cost_usd_offset + max(
1249
- float(tracked_input_cost_usd),
1250
- 0.0,
1251
- )
1252
- except (TypeError, ValueError):
1253
- pass
1254
-
1255
- return total_input_tokens, total_input_cost_usd
1256
-
1257
- async def record_request(
1258
- self,
1259
- provider: str,
1260
- model: str,
1261
- input_tokens: int,
1262
- output_tokens: int,
1263
- tokens_saved: int,
1264
- latency_ms: float,
1265
- cached: bool = False,
1266
- overhead_ms: float = 0,
1267
- ttfb_ms: float = 0,
1268
- pipeline_timing: dict[str, float] | None = None,
1269
- waste_signals: dict[str, int] | None = None,
1270
- cache_read_tokens: int = 0,
1271
- cache_write_tokens: int = 0,
1272
- uncached_input_tokens: int = 0,
1273
- ):
1274
- """Record metrics for a request."""
1275
- async with self._lock:
1276
- self.requests_total += 1
1277
- self.requests_by_provider[provider] += 1
1278
- self.requests_by_model[model] += 1
1279
-
1280
- if cached:
1281
- self.requests_cached += 1
1282
-
1283
- self.tokens_input_total += input_tokens
1284
- self.tokens_output_total += output_tokens
1285
- self.tokens_saved_total += tokens_saved
1286
-
1287
- # Track provider-specific prefix cache metrics
1288
- if cache_read_tokens > 0 or cache_write_tokens > 0:
1289
- pc = self.cache_by_provider[provider]
1290
- pc["cache_read_tokens"] += cache_read_tokens
1291
- pc["cache_write_tokens"] += cache_write_tokens
1292
- pc["requests"] += 1
1293
- if cache_read_tokens > 0:
1294
- pc["hit_requests"] += 1
1295
- # Model-aware bust detection: the first request for any model
1296
- # is always a cold start (100% write, 0% read) — not a bust.
1297
- # Only flag as bust when a previously-warm model suddenly has
1298
- # high write ratio, indicating prefix invalidation.
1299
- model_req_num = self._cache_requests_by_model[model]
1300
- self._cache_requests_by_model[model] += 1
1301
- if provider == "anthropic" and model_req_num > 0:
1302
- total_cached = cache_read_tokens + cache_write_tokens
1303
- if total_cached > 0 and cache_write_tokens > total_cached * 0.5:
1304
- pc["bust_count"] += 1
1305
- pc["bust_write_tokens"] += cache_write_tokens
1306
-
1307
- self.latency_sum_ms += latency_ms
1308
- self.latency_min_ms = min(self.latency_min_ms, latency_ms)
1309
- self.latency_max_ms = max(self.latency_max_ms, latency_ms)
1310
- self.latency_count += 1
1311
-
1312
- # Track Headroom overhead separately
1313
- if overhead_ms > 0:
1314
- self.overhead_sum_ms += overhead_ms
1315
- self.overhead_min_ms = min(self.overhead_min_ms, overhead_ms)
1316
- self.overhead_max_ms = max(self.overhead_max_ms, overhead_ms)
1317
- self.overhead_count += 1
1318
-
1319
- # Track TTFB (time to first byte from upstream)
1320
- if ttfb_ms > 0:
1321
- self.ttfb_sum_ms += ttfb_ms
1322
- self.ttfb_min_ms = min(self.ttfb_min_ms, ttfb_ms)
1323
- self.ttfb_max_ms = max(self.ttfb_max_ms, ttfb_ms)
1324
- self.ttfb_count += 1
1325
-
1326
- # Track per-transform timing
1327
- if pipeline_timing:
1328
- for name, ms in pipeline_timing.items():
1329
- self.transform_timing_sum[name] += ms
1330
- self.transform_timing_count[name] += 1
1331
- self.transform_timing_max[name] = max(self.transform_timing_max[name], ms)
1332
-
1333
- # Track waste signals
1334
- if waste_signals:
1335
- for signal_name, token_count in waste_signals.items():
1336
- self.waste_signals_total[signal_name] += token_count
1337
-
1338
- # Track cumulative savings history (record every request)
1339
- from datetime import datetime
1340
-
1341
- self.savings_history.append((datetime.now().isoformat(), self.tokens_saved_total))
1342
- # Keep last 500 data points
1343
- if len(self.savings_history) > 500:
1344
- self.savings_history = self.savings_history[-500:]
1345
-
1346
- total_input_tokens, total_input_cost_usd = self._current_savings_tracker_totals()
1347
- self.savings_tracker.record_request(
1348
- model=model,
1349
- input_tokens=input_tokens,
1350
- tokens_saved=tokens_saved,
1351
- cache_read_tokens=cache_read_tokens,
1352
- cache_write_tokens=cache_write_tokens,
1353
- uncached_input_tokens=uncached_input_tokens,
1354
- total_input_tokens=total_input_tokens,
1355
- total_input_cost_usd=total_input_cost_usd,
1356
- )
1357
-
1358
- async def record_rate_limited(self):
1359
- async with self._lock:
1360
- self.requests_rate_limited += 1
1361
-
1362
- async def record_failed(self):
1363
- async with self._lock:
1364
- self.requests_failed += 1
1365
-
1366
- async def export(self) -> str:
1367
- """Export metrics in Prometheus format."""
1368
- async with self._lock:
1369
- lines = [
1370
- "# HELP headroom_requests_total Total number of requests",
1371
- "# TYPE headroom_requests_total counter",
1372
- f"headroom_requests_total {self.requests_total}",
1373
- "",
1374
- "# HELP headroom_requests_cached_total Cached request count",
1375
- "# TYPE headroom_requests_cached_total counter",
1376
- f"headroom_requests_cached_total {self.requests_cached}",
1377
- "",
1378
- "# HELP headroom_requests_rate_limited_total Rate limited requests",
1379
- "# TYPE headroom_requests_rate_limited_total counter",
1380
- f"headroom_requests_rate_limited_total {self.requests_rate_limited}",
1381
- "",
1382
- "# HELP headroom_requests_failed_total Failed requests",
1383
- "# TYPE headroom_requests_failed_total counter",
1384
- f"headroom_requests_failed_total {self.requests_failed}",
1385
- "",
1386
- "# HELP headroom_tokens_input_total Total input tokens",
1387
- "# TYPE headroom_tokens_input_total counter",
1388
- f"headroom_tokens_input_total {self.tokens_input_total}",
1389
- "",
1390
- "# HELP headroom_tokens_output_total Total output tokens",
1391
- "# TYPE headroom_tokens_output_total counter",
1392
- f"headroom_tokens_output_total {self.tokens_output_total}",
1393
- "",
1394
- "# HELP headroom_tokens_saved_total Tokens saved by optimization",
1395
- "# TYPE headroom_tokens_saved_total counter",
1396
- f"headroom_tokens_saved_total {self.tokens_saved_total}",
1397
- "",
1398
- "# HELP headroom_latency_ms_sum Sum of request latencies",
1399
- "# TYPE headroom_latency_ms_sum counter",
1400
- f"headroom_latency_ms_sum {self.latency_sum_ms:.2f}",
1401
- ]
1402
-
1403
- # Per-provider metrics
1404
- lines.extend(
1405
- [
1406
- "",
1407
- "# HELP headroom_requests_by_provider Requests by provider",
1408
- "# TYPE headroom_requests_by_provider counter",
1409
- ]
1410
- )
1411
- for provider, count in self.requests_by_provider.items():
1412
- lines.append(f'headroom_requests_by_provider{{provider="{provider}"}} {count}')
1413
-
1414
- # Per-model metrics
1415
- lines.extend(
1416
- [
1417
- "",
1418
- "# HELP headroom_requests_by_model Requests by model",
1419
- "# TYPE headroom_requests_by_model counter",
1420
- ]
1421
- )
1422
- for model, count in self.requests_by_model.items():
1423
- lines.append(f'headroom_requests_by_model{{model="{model}"}} {count}')
1424
-
1425
- return "\n".join(lines)
1426
-
1427
-
1428
- # =============================================================================
1429
- # Request Logger
1430
- # =============================================================================
1431
-
1432
-
1433
- class RequestLogger:
1434
- """Log requests to JSONL file.
1435
-
1436
- Uses a deque with max 10,000 entries to prevent unbounded memory growth.
1437
- """
1438
-
1439
- MAX_LOG_ENTRIES = 10_000
1440
-
1441
- def __init__(self, log_file: str | None = None, log_full_messages: bool = False):
1442
- self.log_file = Path(log_file) if log_file else None
1443
- self.log_full_messages = log_full_messages
1444
- # Use deque with maxlen for automatic FIFO eviction
1445
- self._logs: deque[RequestLog] = deque(maxlen=self.MAX_LOG_ENTRIES)
1446
-
1447
- if self.log_file:
1448
- self.log_file.parent.mkdir(parents=True, exist_ok=True)
1449
-
1450
- def log(self, entry: RequestLog):
1451
- """Log a request. Oldest entries are automatically removed when limit reached."""
1452
- self._logs.append(entry)
1453
-
1454
- if self.log_file:
1455
- with open(self.log_file, "a") as f:
1456
- log_dict = asdict(entry)
1457
- if not self.log_full_messages:
1458
- log_dict.pop("request_messages", None)
1459
- log_dict.pop("response_content", None)
1460
- f.write(json.dumps(log_dict) + "\n")
1461
-
1462
- def get_recent(self, n: int = 100) -> list[dict]:
1463
- """Get recent log entries."""
1464
- # Convert deque to list for slicing (deque doesn't support slicing)
1465
- entries = list(self._logs)[-n:]
1466
- return [
1467
- {
1468
- k: v
1469
- for k, v in asdict(e).items()
1470
- if k not in ("request_messages", "response_content")
1471
- }
1472
- for e in entries
1473
- ]
1474
-
1475
- def stats(self) -> dict:
1476
- """Get logging statistics."""
1477
- return {
1478
- "total_logged": len(self._logs),
1479
- "log_file": str(self.log_file) if self.log_file else None,
1480
- }
1481
-
1482
- def get_memory_stats(self) -> ComponentStats:
1483
- """Get memory statistics for the MemoryTracker.
1484
-
1485
- Returns:
1486
- ComponentStats with current memory usage.
1487
- """
1488
- from ..memory.tracker import ComponentStats
1489
-
1490
- # Calculate size
1491
- size_bytes = sys.getsizeof(self._logs)
1492
-
1493
- for log_entry in self._logs:
1494
- size_bytes += sys.getsizeof(log_entry)
1495
- # Add string fields
1496
- if log_entry.request_id:
1497
- size_bytes += len(log_entry.request_id)
1498
- if log_entry.provider:
1499
- size_bytes += len(log_entry.provider)
1500
- if log_entry.model:
1501
- size_bytes += len(log_entry.model)
1502
- if log_entry.error:
1503
- size_bytes += len(log_entry.error)
1504
- # Messages and response can be large
1505
- if log_entry.request_messages:
1506
- size_bytes += sys.getsizeof(log_entry.request_messages)
1507
- if log_entry.response_content:
1508
- size_bytes += len(log_entry.response_content)
1509
-
1510
- return ComponentStats(
1511
- name="request_logger",
1512
- entry_count=len(self._logs),
1513
- size_bytes=size_bytes,
1514
- budget_bytes=None,
1515
- hits=0,
1516
- misses=0,
1517
- evictions=0,
1518
- )
1519
-
1520
-
1521
- # =============================================================================
1522
- # Main Proxy
1523
- # =============================================================================
1524
-
1525
-
1526
  class HeadroomProxy:
1527
  """Production-ready Headroom optimization proxy."""
1528
 
 
25
 
26
  import argparse
27
  import asyncio
 
28
  import json
29
  import logging
30
  import os
31
  import random
32
  import sys
33
  import time
34
+ from datetime import datetime
 
 
35
  from pathlib import Path
36
  from typing import TYPE_CHECKING, Any, Literal
37
 
38
  if TYPE_CHECKING:
39
  from ..cache.compression_cache import CompressionCache
40
+ from ..memory.tracker import MemoryTracker
41
 
42
  import contextlib
43
 
 
85
  )
86
  from headroom.dashboard import get_dashboard_html
87
  from headroom.providers import AnthropicProvider, OpenAIProvider
88
+
89
+ # =============================================================================
90
+ # Extracted modules (re-exported for backward compatibility)
91
+ # =============================================================================
92
+ from headroom.proxy.cost import (
93
+ _CACHE_ECONOMICS, # noqa: F401
94
+ CostTracker, # noqa: F401
95
+ _summarize_transforms, # noqa: F401
96
+ )
97
+ from headroom.proxy.cost import build_prefix_cache_stats as _build_prefix_cache_stats # noqa: F401
98
+ from headroom.proxy.cost import build_session_summary as _build_session_summary # noqa: F401
99
+ from headroom.proxy.cost import merge_cost_stats as _merge_cost_stats # noqa: F401
100
+ from headroom.proxy.helpers import (
101
+ COMPRESSION_TIMEOUT_SECONDS, # noqa: F401
102
+ MAX_COMPRESSION_CACHE_SESSIONS, # noqa: F401
103
+ MAX_MESSAGE_ARRAY_LENGTH, # noqa: F401
104
+ MAX_REQUEST_BODY_SIZE, # noqa: F401
105
+ MAX_SSE_BUFFER_SIZE, # noqa: F401
106
+ _get_image_compressor, # noqa: F401
107
+ _get_rtk_stats, # noqa: F401
108
+ _read_request_json, # noqa: F401
109
+ _setup_file_logging, # noqa: F401
110
+ )
111
  from headroom.proxy.memory_handler import MemoryConfig, MemoryHandler
112
 
113
  # Data models (extracted to headroom/proxy/models.py for maintainability)
114
  from headroom.proxy.models import CacheEntry, ProxyConfig, RateLimitState, RequestLog # noqa: F401
115
+ from headroom.proxy.prometheus_metrics import PrometheusMetrics # noqa: F401
116
+ from headroom.proxy.rate_limiter import TokenBucketRateLimiter # noqa: F401
117
+ from headroom.proxy.request_logger import RequestLogger # noqa: F401
118
+ from headroom.proxy.semantic_cache import SemanticCache # noqa: F401
119
  from headroom.telemetry import get_telemetry_collector
120
  from headroom.telemetry.toin import get_toin
121
  from headroom.tokenizers import get_tokenizer
 
134
  )
135
  from headroom.utils import extract_user_query
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  logging.basicConfig(
138
  level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
139
  )
 
143
  _HEADROOM_LOG_DIR = Path.home() / ".headroom" / "logs"
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  _setup_file_logging()
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  # Maximum rate limiter buckets (prevents DoS via spoofed API keys)
150
  MAX_RATE_LIMITER_BUCKETS = 1000
151
 
152
  # Compression pipeline timeout in seconds
 
153
 
154
 
155
  # =============================================================================
 
157
  # =============================================================================
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  class HeadroomProxy:
161
  """Production-ready Headroom optimization proxy."""
162
 
pyproject.toml CHANGED
@@ -249,6 +249,12 @@ ignore_missing_imports = true
249
  [[tool.mypy.overrides]]
250
  module = [
251
  "headroom.proxy.server",
 
 
 
 
 
 
252
  "headroom.integrations.langchain",
253
  "headroom.integrations.mcp",
254
  "headroom.ccr.mcp_server",
 
249
  [[tool.mypy.overrides]]
250
  module = [
251
  "headroom.proxy.server",
252
+ "headroom.proxy.cost",
253
+ "headroom.proxy.prometheus_metrics",
254
+ "headroom.proxy.semantic_cache",
255
+ "headroom.proxy.rate_limiter",
256
+ "headroom.proxy.request_logger",
257
+ "headroom.proxy.helpers",
258
  "headroom.integrations.langchain",
259
  "headroom.integrations.mcp",
260
  "headroom.ccr.mcp_server",
tests/test_proxy_streaming_resilience.py CHANGED
@@ -83,8 +83,8 @@ class TestModelResolutionCaching:
83
  from headroom.proxy.server import CostTracker
84
 
85
  with (
86
- patch("headroom.proxy.server.LITELLM_AVAILABLE", True),
87
- patch("headroom.proxy.server.litellm") as mock_litellm,
88
  ):
89
  # First call (bare name) fails, second call (prefixed) succeeds
90
  mock_litellm.cost_per_token.side_effect = [
@@ -100,8 +100,8 @@ class TestModelResolutionCaching:
100
  from headroom.proxy.server import CostTracker
101
 
102
  with (
103
- patch("headroom.proxy.server.LITELLM_AVAILABLE", True),
104
- patch("headroom.proxy.server.litellm") as mock_litellm,
105
  ):
106
  mock_litellm.cost_per_token.side_effect = [
107
  Exception("Unknown model"),
@@ -116,8 +116,8 @@ class TestModelResolutionCaching:
116
  from headroom.proxy.server import CostTracker
117
 
118
  with (
119
- patch("headroom.proxy.server.LITELLM_AVAILABLE", True),
120
- patch("headroom.proxy.server.litellm") as mock_litellm,
121
  ):
122
  mock_litellm.cost_per_token.side_effect = [
123
  Exception("Unknown model"),
@@ -132,8 +132,8 @@ class TestModelResolutionCaching:
132
  from headroom.proxy.server import CostTracker
133
 
134
  with (
135
- patch("headroom.proxy.server.LITELLM_AVAILABLE", True),
136
- patch("headroom.proxy.server.litellm") as mock_litellm,
137
  ):
138
  mock_litellm.cost_per_token.side_effect = Exception("Unknown model")
139
 
@@ -144,7 +144,7 @@ class TestModelResolutionCaching:
144
  """When litellm is not available, return model as-is."""
145
  from headroom.proxy.server import CostTracker
146
 
147
- with patch("headroom.proxy.server.LITELLM_AVAILABLE", False):
148
  result = CostTracker._resolve_litellm_model_uncached("claude-opus-4-6")
149
  assert result == "claude-opus-4-6"
150
 
@@ -153,8 +153,8 @@ class TestModelResolutionCaching:
153
  from headroom.proxy.server import CostTracker
154
 
155
  with (
156
- patch("headroom.proxy.server.LITELLM_AVAILABLE", True),
157
- patch("headroom.proxy.server.litellm") as mock_litellm,
158
  ):
159
  mock_litellm.cost_per_token.return_value = (0.001, 0.002)
160
 
@@ -516,8 +516,8 @@ class TestConcurrentSessionSafety:
516
  CostTracker._resolved_model_cache["gpt-4o"] = "openai/gpt-4o"
517
 
518
  with (
519
- patch("headroom.proxy.server.LITELLM_AVAILABLE", True),
520
- patch("headroom.proxy.server.litellm") as mock_litellm,
521
  ):
522
  mock_litellm.cost_per_token.return_value = (0.001, 0.002)
523
  mock_litellm.get_model_info.return_value = {}
@@ -555,8 +555,8 @@ class TestCostTrackingAccuracy:
555
  tracker = CostTracker()
556
 
557
  with (
558
- patch("headroom.proxy.server.LITELLM_AVAILABLE", True),
559
- patch("headroom.proxy.server.litellm") as mock_litellm,
560
  ):
561
  # Setup: $10/M input, $30/M output
562
  def mock_cost(model, prompt_tokens, completion_tokens, **kwargs):
@@ -601,8 +601,8 @@ class TestCostTrackingAccuracy:
601
  tracker = CostTracker()
602
 
603
  with (
604
- patch("headroom.proxy.server.LITELLM_AVAILABLE", True),
605
- patch("headroom.proxy.server.litellm") as mock_litellm,
606
  ):
607
  mock_litellm.cost_per_token.side_effect = (
608
  lambda model, prompt_tokens, completion_tokens, **kwargs: (
@@ -623,6 +623,6 @@ class TestCostTrackingAccuracy:
623
 
624
  tracker = CostTracker()
625
 
626
- with patch("headroom.proxy.server.LITELLM_AVAILABLE", False):
627
  cost = tracker.estimate_cost("gpt-4o", input_tokens=1000, output_tokens=100)
628
  assert cost is None
 
83
  from headroom.proxy.server import CostTracker
84
 
85
  with (
86
+ patch("headroom.proxy.cost.LITELLM_AVAILABLE", True),
87
+ patch("headroom.proxy.cost.litellm") as mock_litellm,
88
  ):
89
  # First call (bare name) fails, second call (prefixed) succeeds
90
  mock_litellm.cost_per_token.side_effect = [
 
100
  from headroom.proxy.server import CostTracker
101
 
102
  with (
103
+ patch("headroom.proxy.cost.LITELLM_AVAILABLE", True),
104
+ patch("headroom.proxy.cost.litellm") as mock_litellm,
105
  ):
106
  mock_litellm.cost_per_token.side_effect = [
107
  Exception("Unknown model"),
 
116
  from headroom.proxy.server import CostTracker
117
 
118
  with (
119
+ patch("headroom.proxy.cost.LITELLM_AVAILABLE", True),
120
+ patch("headroom.proxy.cost.litellm") as mock_litellm,
121
  ):
122
  mock_litellm.cost_per_token.side_effect = [
123
  Exception("Unknown model"),
 
132
  from headroom.proxy.server import CostTracker
133
 
134
  with (
135
+ patch("headroom.proxy.cost.LITELLM_AVAILABLE", True),
136
+ patch("headroom.proxy.cost.litellm") as mock_litellm,
137
  ):
138
  mock_litellm.cost_per_token.side_effect = Exception("Unknown model")
139
 
 
144
  """When litellm is not available, return model as-is."""
145
  from headroom.proxy.server import CostTracker
146
 
147
+ with patch("headroom.proxy.cost.LITELLM_AVAILABLE", False):
148
  result = CostTracker._resolve_litellm_model_uncached("claude-opus-4-6")
149
  assert result == "claude-opus-4-6"
150
 
 
153
  from headroom.proxy.server import CostTracker
154
 
155
  with (
156
+ patch("headroom.proxy.cost.LITELLM_AVAILABLE", True),
157
+ patch("headroom.proxy.cost.litellm") as mock_litellm,
158
  ):
159
  mock_litellm.cost_per_token.return_value = (0.001, 0.002)
160
 
 
516
  CostTracker._resolved_model_cache["gpt-4o"] = "openai/gpt-4o"
517
 
518
  with (
519
+ patch("headroom.proxy.cost.LITELLM_AVAILABLE", True),
520
+ patch("headroom.proxy.cost.litellm") as mock_litellm,
521
  ):
522
  mock_litellm.cost_per_token.return_value = (0.001, 0.002)
523
  mock_litellm.get_model_info.return_value = {}
 
555
  tracker = CostTracker()
556
 
557
  with (
558
+ patch("headroom.proxy.cost.LITELLM_AVAILABLE", True),
559
+ patch("headroom.proxy.cost.litellm") as mock_litellm,
560
  ):
561
  # Setup: $10/M input, $30/M output
562
  def mock_cost(model, prompt_tokens, completion_tokens, **kwargs):
 
601
  tracker = CostTracker()
602
 
603
  with (
604
+ patch("headroom.proxy.cost.LITELLM_AVAILABLE", True),
605
+ patch("headroom.proxy.cost.litellm") as mock_litellm,
606
  ):
607
  mock_litellm.cost_per_token.side_effect = (
608
  lambda model, prompt_tokens, completion_tokens, **kwargs: (
 
623
 
624
  tracker = CostTracker()
625
 
626
+ with patch("headroom.proxy.cost.LITELLM_AVAILABLE", False):
627
  cost = tracker.estimate_cost("gpt-4o", input_tokens=1000, output_tokens=100)
628
  assert cost is None