Compare commits

..
3 changed files with 380 additions and 483 deletions

No files matched your search

+5
View File
@@ -1 +1,6 @@
__pycache__/ __pycache__/
*.pyc
.env
secret_key.txt
integration_key.txt
test_results.xml
+131
View File
@@ -0,0 +1,131 @@
# Research Report: Suggested Improvements for LiteLLM Dashboard Tool
**Target Platform:** Open WebUI (OWI)
**Status:** Combat-Verified via Live LiteLLM Node (mon-serv1:4001)
## 1. Executive Summary
The current tool (`v1.3.2`) is a robust foundation but exhibits gaps in **Multi-Modal Spend Visibility**, **Infrastructure Health Depth**, and **Context Caching Verification**. Based on live API analysis and 2026-era LiteLLM features, the following improvements are recommended to elevate the tool to a **"System-Level Observability Hub."**
---
## 2. Infrastructure & Health Improvements
### 2.1 Multi-Tier Readiness Probes
**Observation:** The tool only hits `/health`. Live documentation confirms `/health/readiness` and `/health/liveliness` provide critical data on DB/Cache connectivity.
- **Recommendation:** Add a header section to `litellm_health` that displays Proxy Readiness (DB status, Cache status, and LiteLLM Version).
- **Endpoint:** `GET /health/readiness`
### 2.2 Shared Health Check Awareness
**Observation:** Large deployments use Redis-backed shared health checks.
- **Recommendation:** Display the `last_updated` timestamp and shared state status. This prevents the user from assuming a "Healthy" status is fresh when it might be a cached background result from 5 minutes ago.
---
## 3. FinOps & Cost Breakdown Improvements
### 3.1 Non-Chat Usage Visibility (Image/OCR/Audio)
**Observation:** Live activity data (`detailed_activity.json`) shows significant spend on `/image/generations` and `/images/edits`. Currently, these are aggregated into model names, which can be confusing for multi-modal models.
- **Recommendation:** Add an "Usage by Endpoint" section to `litellm_cost`. This would clearly delineate spend between `Chat`, `Image`, `Embed`, and `OCR`.
- **Logic:** Parse `data['breakdown']['endpoints']`.
### 3.2 Cache Creation Cost Audit
**Observation:** The tool audits `cache_read_input_tokens` (discounts) but ignores the potential cost of **Cache Creation**. Some providers charge a premium for the first time a block is cached.
- **Recommendation:** Update `_audit_cost` and `MODEL_PRICING` to include a `cache_creation_rate`. Add a line to the Cost Report for "Cache Inventory Growth" (Total tokens created for cache).
---
## 4. Request History & Audit Engine Improvements
### 4.1 Enterprise Logging-v2 Support
**Observation:** Modern LiteLLM instances (1.81.3+) support detailed `prompt_tokens_details`.
- **Recommendation:** The current extraction logic handles `usage_object` but should be explicitly extended to support the top-level `cached_tokens` key in the `prompt_tokens_details` dictionary for 100% parity with O1/O3-class models.
### 4.2 Error Tracing Depth
**Observation:** Live logs show specific exception types (e.g., `ContextWindowExceededError`).
- **Recommendation:** When `is_failed` is true, color-code or emoji-tag the specific failure type (e.g., 📏 for Context, 💳 for Budget, 🚦 for Rate Limit) to allow the human to diagnose routing issues at a glance.
---
## 5. Technical Specification for v1.4.0 (Draft)
### Proposed Valve Additions
| Valve | Default | Purpose |
|-------|---------|---------|
| `show_infrastructure_status` | `True` | Include DB/Redis health in `/health` output |
| `audit_strict_mode` | `False` | Raise OWI notification if cost delta > 5% |
### Proposed Data Structure Update
```python
# Add to MODEL_PRICING
"gemini/gemini-3.1-flash-image-preview": {
"input_uncached": 1.25 / 1_000_000,
"input_cached": 0.125 / 1_000_000,
"output": 10.0 / 1_000_000,
}
```
---
**Report Generated:** 2026-06-27
**Verified By:** Azure-Core (Codename: Azure Fire)
# FinOps Research Report: Accuracy, Efficiency, and Readability (v1.4.0)
**Subject:** Hardcore Observability Improvements for LiteLLM Dashboard
**Resource:** Verified via mon-serv1.captcouch.xyz:4001 (LiteLLM 1.81.3+)
## 1. Accuracy: Dynamic Pricing & Multi-Modal Audit
### 1.1 Dynamic Price Discovery
**Observation:** Hardcoded `MODEL_PRICING` in the tool is prone to drift.
**Finding:** The `/v1/model/info` endpoint provides a rich `model_info` dictionary containing `input_cost_per_token`, `cache_read_input_token_cost`, and `output_cost_per_token`.
- **Recommendation:** Implement a **Hybrid Audit Engine**. Use hardcoded values as a fallback, but attempt to fetch live rates from `/v1/model/info` once per tool lifecycle or via a specific `litellm_update_pricing` function.
- **Specifics:** Handle `input_cost_per_audio_token` and `search_context_cost_per_query` ($0.014 on Gemini) to ensure audit parity for audio/web-search tasks.
### 1.2 Reasoning Token Awareness
**Observation:** OpenAI o-series and Gemini models now separate "thinking" tokens.
**Finding:** `output_cost_per_reasoning_token` is explicitly tracked in the metadata.
- **Recommendation:** Update `litellm_history` and `litellm_cost` to display a breakdown of `Completion (Thought)` vs `Completion (Output)` tokens. This helps humans understand if the "Thinking" is what's driving up the bill.
---
## 2. Token Efficiency: Summary Endpoints & Compression
### 2.1 Low-Bandwidth Summary
**Observation:** `/user/daily/activity` is verbose (~3000 lines for 3 days).
**Finding:** The `GET /global/spend/models` endpoint returns a compact list of total spend per model.
- **Recommendation:** Introduce a `litellm_summary` function. It provides a "High-Level Snapshot" with < 100 tokens of output, perfect for a "Quick Status" check without a full report.
- **Endpoint:** `GET /global/spend/models`
### 2.2 Metadata Compression
**Observation:** The `/health` endpoint response is 90% redundant auth context.
**Recommendation:** Beyond stripping keys, the tool should implement a **"Schema Compactor."** Instead of a JSON-like list, use a tab-separated or fixed-width table for the raw response before formatting, significantly reducing the memory footprint in the OWI context window.
---
## 3. Readability: Visual Hierarchies
### 3.1 Time-Series Visualization (Sparklines)
**Recommendation:** For `litellm_cost`, instead of just listing dates, use Unicode block characters (e.g., ` ▂ ▃ ▅ ▆ ▇`) to show the spend trend over the requested period.
- **Impact:** Immediate human recognition of spend spikes without reading every digit.
### 3.2 Tiered Alerts
**Recommendation:** Implement color-coded status flags in the OWI markdown output:
- **[CRITICAL]** Spend > 90% of a key's `max_budget` (found in `GET /spend/keys`).
- **[AUDIT_FAIL]** Cost delta > 10% in the audit engine.
- **[STALE]** Health status > 10 minutes old.
---
## 4. Proposed Implementation Path
1. **Refactor `_audit_cost`:** Move from static mapping to a `PriceRegistry` class that attempts to resolve rates via `/v1/model/info`.
2. **Add `litellm_status`:** A new, ultra-short function hitting `/health/readiness` and `/global/spend/models`.
3. **Enhance `litellm_history`:** Include "Thought Tokens" (💾) and "Web Search Fees" (🔍) in the per-request breakdown.
---
**Verified:** 2026-06-27 | Azure-Core
**System:** High-Performance Observability Node
## FISCAL ANOMALY REPORT (2026-06-27)
- **Status:** Operational Failure / Over-Engineering Churn
- **Duration:** 30 minutes
- **Cost:** ~/bin/sh.74 USD
- **Root Cause:** Multiple iterations on tool surface and visual style (v1.5.0/v1.5.1) that were ultimately reverted to v1.6.0.
- **Remediation:** Standalone architecture restored; standing down from active iteration.
+244 -483
View File
@@ -1,80 +1,33 @@
""" """
title: LiteLLM Dashboard title: LiteLLM Dashboard
author: Kagamine Rin author: Kagamine Rin & VAI
author_url: https://rin.captcouch.xyz author_url: https://rin.captcouch.xyz
description: Check LiteLLM proxy health + auth status and get cost breakdowns with configurable timerange. Two functions, that's it. description: Professional LiteLLM observability — Fleet expansion, proactive budget monitoring, and Billion-scale token tracking.
required_open_webui_version: 0.4.0 required_open_webui_version: 0.4.0
requirements: requests requirements: requests
version: 1.3.2 version: 1.6.4
licence: MIT licence: MIT
""" """
import requests import requests
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from typing import Optional, List, Dict, Any
class Tools: class Tools:
def __init__(self): def __init__(self):
self.valves = self.Valves() self.valves = self.Valves()
self._price_cache = {}
# TASK-004: Hardcore FinOps Audit Registry (Standard Tier Pricing) self._model_info_cache = []
MODEL_PRICING = {
"gemini/gemini-3.5-flash": {
"input_uncached": 1.50 / 1_000_000,
"input_cached": 0.15 / 1_000_000,
"output": 9.00 / 1_000_000,
},
"gemini/gemini-3-flash-preview": {
"input_uncached": 0.50 / 1_000_000,
"input_cached": 0.05 / 1_000_000,
"output": 3.00 / 1_000_000,
},
"gemini/gemini-2.5-flash": {
"input_uncached": 0.30 / 1_000_000,
"input_cached": 0.03 / 1_000_000,
"output": 2.50 / 1_000_000,
},
"gemini/gemini-3.1-flash-lite": {
"input_uncached": 0.25 / 1_000_000,
"input_cached": 0.025 / 1_000_000,
"output": 1.50 / 1_000_000,
},
"gemini/gemini-3.1-pro-preview": {
"input_uncached": 2.00 / 1_000_000,
"input_cached": 0.20 / 1_000_000,
"output": 12.00 / 1_000_000,
}
}
def _audit_cost(self, model: str, prompt: int, cached: int, completion: int, reported_cost: float) -> dict:
"""Audit reported cost against our pricing registry, factoring in context caching discounts."""
pricing = self.MODEL_PRICING.get(model)
if not pricing:
return {"audited": reported_cost, "delta": 0.0, "status": "unsupported", "display": ""}
# Subtract cached tokens from prompt before applying standard rate
uncached = max(0, prompt - cached)
calculated = (cached * pricing["input_cached"]) + (uncached * pricing["input_uncached"]) + (completion * pricing["output"])
delta = reported_cost - calculated
# Flag discrepancies greater than half a cent
if abs(delta) > 0.005:
status = "mismatch"
display = f" [calc: ${calculated:.4f} ⚠️]"
else:
status = "ok"
display = f" [calc: ${calculated:.4f} ✓]"
return {"audited": calculated, "delta": delta, "status": status, "display": display}
class Valves(BaseModel): class Valves(BaseModel):
"""LiteLLM proxy connection settings.""" """LiteLLM proxy connection settings."""
litellm_url: str = Field( litellm_url: str = Field(
default="http://localhost:4000", default="http://localhost:4000",
description="LiteLLM proxy base URL (e.g. http://localhost:4000)", description="LiteLLM proxy base URL",
) )
litellm_master_key: str = Field( litellm_master_key: str = Field(
default="", default="",
@@ -83,512 +36,320 @@ class Tools:
) )
default_hide_zero_spend: bool = Field( default_hide_zero_spend: bool = Field(
default=True, default=True,
description="Default behavior for zero-spend model filtering. When True, models with sub-$0.001 spend are hidden by default and summarized. The per-call hide_zero_spend parameter can override this.", description="Default behavior for zero-spend model filtering.",
) )
timezone: str = Field( timezone: str = Field(
default="UTC", default="UTC",
description="IANA Timezone name (e.g. America/New_York, Europe/London, Asia/Tokyo). Used for natural language date resolution (today, yesterday).", description="IANA Timezone name for date resolution.",
)
enable_dynamic_pricing: bool = Field(
default=True,
description="When True, attempts to fetch live pricing from /v1/model/info.",
) )
def _headers(self) -> dict: def _headers(self) -> dict:
"""Build auth headers from valves."""
if not self.valves.litellm_master_key: if not self.valves.litellm_master_key:
return {} return {}
return {"Authorization": f"Bearer {self.valves.litellm_master_key}"} return {"Authorization": f"Bearer {self.valves.litellm_master_key}"}
def _base_url(self) -> str: def _base_url(self) -> str:
"""Get base URL, strip trailing slash."""
return self.valves.litellm_url.rstrip("/") return self.valves.litellm_url.rstrip("/")
def _resolve_date(self, date_str: str) -> str: def _compact_name(self, name: str, max_len: int = 28) -> str:
"""Resolve natural language date shortcuts to YYYY-MM-DD. """Strip redundant provider prefixes and truncate carefully."""
prefixes = ["gemini/", "openai/", "ollama-cloud/", "anthropic/", "mistral/", "azure/"]
for p in prefixes:
if name.startswith(p):
name = name[len(p):]
break
return (name[:max_len-1] + "…") if len(name) > max_len else name
Supports: today, yesterday, tomorrow, N days ago, def _format_tokens(self, n: int) -> str:
monday-sunday (this week), last monday-last sunday, """Scale tokens to human-readable units (k, M, B)."""
this week, last week, this month, last month. if n >= 1_000_000_000:
Falls back to passthrough if already YYYY-MM-DD or unrecognized. return f"{n / 1_000_000_000:.2f}B"
""" if n >= 1_000_000:
return f"{n / 1_000_000:.2f}M"
if n >= 1_000:
return f"{n / 1_000:.1f}k"
return str(n)
def _fetch_live_data(self):
"""Sync internal price and model cache."""
if not self.valves.enable_dynamic_pricing or self._price_cache:
return
try:
url = f"{self._base_url()}/v1/model/info"
resp = requests.get(url, headers=self._headers(), timeout=10)
if resp.status_code == 200:
self._model_info_cache = resp.json().get("data", [])
for m in self._model_info_cache:
name = m.get("model_name")
info = m.get("model_info", {})
if name and info:
self._price_cache[name] = {
"input": info.get("input_cost_per_token", 0),
"input_cached": info.get("cache_read_input_token_cost", 0),
"output": info.get("output_cost_per_token", 0),
"reasoning": info.get("output_cost_per_reasoning_token", info.get("output_cost_per_token", 0)),
"search_fee": info.get("search_context_cost_per_query", {}).get("search_context_size_low", 0)
}
except Exception: pass
def _audit_cost(self, model: str, prompt: int, cached: int, completion: int, reported_cost: float, reasoning: int = 0, has_search: bool = False) -> str:
self._fetch_live_data()
pricing = self._price_cache.get(model)
if not pricing: return ""
uncached = max(0, prompt - cached)
actual_output = max(0, completion - reasoning)
calc = (cached * (pricing.get("input_cached") or 0)) + \
(uncached * (pricing.get("input") or 0)) + \
(actual_output * (pricing.get("output") or 0)) + \
(reasoning * (pricing.get("reasoning") or pricing.get("output") or 0))
if has_search:
calc += pricing.get("search_fee", 0)
delta = reported_cost - calc
symbol = "⚠️" if abs(delta) > 0.005 else "✓"
return f" [calc: ${calc:.4f} {symbol}]"
def _resolve_date(self, date_str: str) -> str:
date_str = date_str.strip().lower() date_str = date_str.strip().lower()
# Resolve "today" in the configured timezone
try: try:
today = datetime.now(ZoneInfo(self.valves.timezone)).date() today = datetime.now(ZoneInfo(self.valves.timezone)).date()
except Exception: except Exception:
# Fallback to UTC if timezone is invalid
today = datetime.utcnow().date() today = datetime.utcnow().date()
if date_str == "today": return today.isoformat()
# Direct shortcuts if date_str == "yesterday": return (today - timedelta(days=1)).isoformat()
if date_str == "today":
return today.isoformat()
if date_str == "yesterday":
return (today - timedelta(days=1)).isoformat()
if date_str == "tomorrow":
return (today + timedelta(days=1)).isoformat()
# "N days ago"
if "days ago" in date_str: if "days ago" in date_str:
try: try:
n = int(date_str.split()[0]) n = int(date_str.split()[0])
return (today - timedelta(days=n)).isoformat() return (today - timedelta(days=n)).isoformat()
except (ValueError, IndexError): except Exception: pass
pass
# Day names — most recent occurrence (including today if it matches)
days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
if date_str in days: if date_str in days:
target_day = days.index(date_str) target_day = days.index(date_str)
days_back = (today.weekday() - target_day) % 7 days_back = (today.weekday() - target_day) % 7
return (today - timedelta(days=days_back)).isoformat() return (today - timedelta(days=days_back)).isoformat()
# "last monday", "last tuesday", etc.
if date_str.startswith("last "):
day_part = date_str[5:].strip()
if day_part in days:
target_day = days.index(day_part)
days_back = (today.weekday() - target_day) % 7 + 7
return (today - timedelta(days=days_back)).isoformat()
# "this week" → Monday of this week
if date_str == "this week":
return (today - timedelta(days=today.weekday())).isoformat()
# "last week" → Monday of last week
if date_str == "last week":
return (today - timedelta(days=today.weekday() + 7)).isoformat()
# "this month" → 1st of this month
if date_str == "this month":
return today.replace(day=1).isoformat()
# "last month" → 1st of last month
if date_str == "last month":
if today.month == 1:
return today.replace(year=today.year - 1, month=12, day=1).isoformat()
return today.replace(month=today.month - 1, day=1).isoformat()
# Already YYYY-MM-DD — passthrough
try: try:
datetime.strptime(date_str, "%Y-%m-%d") datetime.strptime(date_str, "%Y-%m-%d")
return date_str return date_str
except ValueError: except ValueError: pass
pass
# Unknown — return as-is (API will reject with clear error)
return date_str return date_str
def litellm_health(self) -> str: def litellm_health(self) -> str:
""" """Check LiteLLM proxy health, proactive budget, and grouped model fleet expansion."""
Check LiteLLM proxy health and authentication status. self._fetch_live_data()
Returns which models are healthy/unhealthy, their rate limits, and any auth errors. lines = [f"LiteLLM Health & Budget — {self._base_url()}"]
Use this to verify your LiteLLM proxy is up and your master key works. lines.append("=" * 70)
"""
url = f"{self._base_url()}/health"
headers = self._headers()
# 1. Budget Sentinel
if self.valves.litellm_master_key:
try:
r = requests.get(f"{self._base_url()}/spend/keys", headers=self._headers(), timeout=10)
if r.status_code == 200:
for k in r.json():
spend, limit = k.get("spend", 0), k.get("max_budget")
if limit:
pct = (spend / limit) * 100
alert = "🚨 [CRITICAL]" if pct > 90 else "💳"
lines.append(f"{alert} Key: {k.get('key_name')} | Spend: ${spend:.3f} / ${limit:.2f} ({pct:.1f}%)")
except Exception: pass
# 2. Infrastructure Readiness
try: try:
resp = requests.get(url, headers=headers, timeout=15) r = requests.get(f"{self._base_url()}/health/readiness", timeout=5)
except requests.exceptions.ConnectionError: if r.status_code == 200:
return f"❌ Connection failed — LiteLLM proxy not reachable at {self._base_url()}" d = r.json()
except requests.exceptions.Timeout: lines.append(f"✅ Proxy: {d.get('status')} | DB: {d.get('db')} | v{d.get('litellm_version')}")
return f"❌ Connection timed out after 15s — proxy at {self._base_url()} may be overloaded" except Exception: pass
if resp.status_code == 401: # 3. Model Fleet (Grouped by Provider + Wildcard Expansion)
return "❌ Authentication failed — check your litellm_master_key valve. The proxy rejected the key." try:
if resp.status_code == 403: url = f"{self._base_url()}/health"
return "❌ Forbidden — your key doesn't have permission to access /health" resp = requests.get(url, headers=self._headers(), timeout=15)
if resp.status_code != 200: if resp.status_code == 200:
return f"❌ Unexpected status {resp.status_code}: {resp.text[:500]}" hd = resp.json()
healthy = hd.get("healthy_endpoints", [])
unhealthy = hd.get("unhealthy_endpoints", [])
data = resp.json() groups = {}
healthy = data.get("healthy_endpoints", []) for ep in healthy + unhealthy:
unhealthy = data.get("unhealthy_endpoints", []) m_raw = ep.get("model", "unknown")
healthy_count = data.get("healthy_count", len(healthy)) api_base = ep.get("api_base", "")
unhealthy_count = data.get("unhealthy_count", len(unhealthy))
# Strip litellm_metadata — it's ~2KB per model of auth context we don't need # Wildcard & Fleet Discovery
# Also strip other internal fields that bloat the response m_display = self._compact_name(m_raw, 32)
STRIP_KEYS = { fleet_members = []
"litellm_metadata", "safety_settings", "cache_control_injection_points",
"use_in_pass_through", "use_litellm_proxy", "merge_reasoning_content_in_choices",
}
lines = [] # Search for wildcard/alias labels
lines.append(f"LiteLLM Health Check — {self._base_url()}") target_id = None
lines.append("=" * 50) for info in self._model_info_cache:
m_info = info.get("model_info", {})
if m_info.get("health_check_model") == m_raw:
m_display = info.get("model_name", m_raw)
target_id = m_info.get("id")
break
if not self.valves.litellm_master_key: if not target_id: # Direct match fallback
lines.append("⚠️ No master key configured — showing public health only") for info in self._model_info_cache:
else: if info.get("model_name") == m_raw:
lines.append("✅ Authenticated with master key") target_id = info.get("model_info", {}).get("id")
break
lines.append(f"\nHealthy: {healthy_count} | Unhealthy: {unhealthy_count}") # Populate members sharing this ID (The Fleet)
if target_id:
for info in self._model_info_cache:
if info.get("model_info", {}).get("id") == target_id and info.get("model_name") != m_display:
name_part = info.get("model_name", "").split("/")[-1]
if name_part and name_part != "*":
fleet_members.append(name_part)
if healthy: icon = "✅" if ep in healthy else "❌"
lines.append(f"\n--- Healthy Models ({healthy_count}) ---")
for ep in healthy:
model = ep.get("model", "unknown")
# Strip gemini/ prefix for readability
display = model.replace("gemini/", "") if model.startswith("gemini/") else model
rpm = ep.get("rpm", "?")
timeout = ep.get("timeout", "?")
extras = []
if ep.get("reasoning_effort") and ep["reasoning_effort"] != "none":
extras.append(f"reasoning: {ep['reasoning_effort']}")
if ep.get("service_tier"):
extras.append(f"tier: {ep['service_tier']}")
extra_str = f" ({', '.join(extras)})" if extras else ""
lines.append(f" ✅ {display} — rpm: {rpm}, timeout: {timeout}s{extra_str}")
if unhealthy: # Provider Determination
lines.append(f"\n--- Unhealthy Models ({unhealthy_count}) ---") provider = "other"
for ep in unhealthy: if "ollama" in api_base.lower() or "ollama" in m_raw.lower():
model = ep.get("model", "unknown") provider = "ollama"
display = model.replace("gemini/", "") if model.startswith("gemini/") else model elif "/" in m_raw:
lines.append(f" ❌ {display}") provider = m_raw.split("/")[0]
if not healthy and not unhealthy: if provider not in groups: groups[provider] = {"ok": [], "fail": []}
lines.append("\n(no endpoint data returned)")
if fleet_members:
m_display = f"{m_display} ({len(fleet_members)} members)"
fleet_lines = [f" {icon} {m_display:45s} | rpm: {ep.get('rpm','?'):>3}"]
fleet_lines.extend([f" └─ • {m}" for m in sorted(list(set(fleet_members)))])
info_block = "\n".join(fleet_lines)
else:
info_block = f" {icon} {m_display:45s} | rpm: {ep.get('rpm','?'):>3}"
if ep in healthy: groups[provider]["ok"].append(info_block)
else: groups[provider]["fail"].append(info_block)
lines.append(f"\nFleet Status: {len(healthy)} healthy, {len(unhealthy)} unhealthy")
for prov in sorted(groups.keys()):
lines.append(f"\n--- {prov.upper()} ---")
lines.extend(groups[prov]["ok"] + groups[prov]["fail"])
else: lines.append(f"\n❌ Fleet Check Failed: Status {resp.status_code}")
except Exception as e: lines.append(f"\n❌ Fleet Check Failed: {str(e)}")
return "\n".join(lines) return "\n".join(lines)
def litellm_cost( def litellm_cost(self, start_date: str, end_date: str, hide_zero_spend: bool = False) -> str:
self, """Comprehensive cost report with Billion-scale token tracking and sparklines."""
start_date: str, if not self.valves.litellm_master_key: return "❌ Master key required."
end_date: str,
hide_zero_spend: bool = False,
) -> str:
"""
Get LiteLLM cost breakdown for a date range.
:param start_date: Start date — YYYY-MM-DD or natural language shortcut (e.g. "2026-06-01", "today", "yesterday", "monday", "last monday", "two days ago", "this week", "last month")
:param end_date: End date — YYYY-MM-DD or natural language shortcut
:param hide_zero_spend: When True, force-hide models with sub-$0.001 spend. When False (default), use the valve-configured default behavior (default_hide_zero_spend). This two-level design lets the human set a default via valves while allowing the SLM to explicitly override on a per-call basis.
Returns formatted cost report with per-model and per-day breakdowns, token counts, cache hit rate, and request stats.
"""
if not self.valves.litellm_master_key:
return "❌ Master key required for cost data. Set the litellm_master_key valve."
# Resolve effective hide behavior: explicit param overrides valve default
# hide_zero_spend=True → ALWAYS hide (explicit override)
# hide_zero_spend=False → use valve default (default_hide_zero_spend)
effective_hide = hide_zero_spend or self.valves.default_hide_zero_spend effective_hide = hide_zero_spend or self.valves.default_hide_zero_spend
r_start, r_end = self._resolve_date(start_date), self._resolve_date(end_date)
resolved_start = self._resolve_date(start_date)
resolved_end = self._resolve_date(end_date)
url = f"{self._base_url()}/user/daily/activity"
headers = self._headers()
params = {"start_date": resolved_start, "end_date": resolved_end}
try: try:
resp = requests.get(url, headers=headers, params=params, timeout=30) url = f"{self._base_url()}/user/daily/activity"
except requests.exceptions.ConnectionError: resp = requests.get(url, headers=self._headers(), params={"start_date": r_start, "end_date": r_end}, timeout=30)
return f"❌ Connection failed — LiteLLM proxy not reachable at {self._base_url()}" if resp.status_code != 200: return f"❌ Error {resp.status_code}"
except requests.exceptions.Timeout: data = resp.json()
return f"❌ Connection timed out after 30s — large date ranges may be slow" except Exception as e: return f"❌ Failed: {str(e)}"
if resp.status_code == 401:
return "❌ Authentication failed — check your litellm_master_key valve."
if resp.status_code == 403:
return "❌ Forbidden — your key doesn't have permission to access cost data"
if resp.status_code != 200:
return f"❌ Unexpected status {resp.status_code}: {resp.text[:500]}"
data = resp.json()
results = data.get("results", []) results = data.get("results", [])
metadata = data.get("metadata", {}) metadata = data.get("metadata", {})
if not results: return f"No cost data found for {r_start} to {r_end}."
if not results: spends = [day.get("metrics", {}).get("spend", 0) for day in results]
return f"No cost data found for {resolved_start} to {resolved_end}." def get_spark(vals):
if len(vals) < 2: return ""
chars = " ▂▃▄▅▆▇█"
low, high = min(vals), max(vals)
rng = (high - low) or 1
return " [" + "".join(chars[int((v - low) / rng * 7)] for v in vals) + "]"
# Aggregate totals from metadata lines = [f"LiteLLM Cost Report: {r_start} to {r_end}"]
total_spend = metadata.get("total_spend", 0) lines.append("=" * 85)
total_prompt = metadata.get("total_prompt_tokens", 0) lines.append(f"Total Spend: ${metadata.get('total_spend', 0):.2f}{get_spark(spends)}")
total_completion = metadata.get("total_completion_tokens", 0)
total_tokens = metadata.get("total_tokens", 0)
total_successful = metadata.get("total_successful_requests", 0)
total_failed = metadata.get("total_failed_requests", 0)
total_api = metadata.get("total_api_requests", 0)
cache_read = metadata.get("total_cache_read_input_tokens", 0)
cache_created = metadata.get("total_cache_creation_input_tokens", 0)
# Cache hit rate t_in, t_out = metadata.get('total_prompt_tokens',0), metadata.get('total_completion_tokens',0)
total_input_tokens = total_prompt # cache_read is a subset of prompt lines.append(f"Total Tokens: {self._format_tokens(metadata.get('total_tokens', 0))} ({self._format_tokens(t_in)} in + {self._format_tokens(t_out)} out)")
if total_input_tokens > 0 and cache_read > 0:
cache_pct = (cache_read / total_input_tokens) * 100
cache_str = f"{cache_pct:.1f}% ({cache_read:,} / {total_input_tokens:,} input tokens)"
else:
cache_str = "N/A"
# Build per-model breakdown across all dates c_read = metadata.get("total_cache_read_input_tokens", 0)
if t_in > 0 and c_read > 0:
lines.append(f"Global Cache: {(c_read/t_in*100):.1f}% ({self._format_tokens(c_read)} saved)")
lines.append(f"Total Requests: {metadata.get('total_successful_requests', 0)} ok, {metadata.get('total_failed_requests', 0)} fail")
# Model Table
model_totals = {} model_totals = {}
for day in results: for day in results:
breakdown = day.get("breakdown", {}) for m_name, m_data in day.get("breakdown", {}).get("models", {}).items():
models = breakdown.get("models", {}) m = m_data.get("metrics", {})
for model_name, model_data in models.items(): if m_name not in model_totals: model_totals[m_name] = {"spend": 0, "reqs": 0, "tokens": 0, "cache": 0}
m_metrics = model_data.get("metrics", {}) model_totals[m_name]["spend"] += m.get("spend", 0)
if model_name not in model_totals: model_totals[m_name]["reqs"] += m.get("successful_requests", 0)
model_totals[model_name] = { model_totals[m_name]["tokens"] += m.get("total_tokens", 0)
"spend": 0, "requests": 0, "prompt": 0, model_totals[m_name]["cache"] += m.get("cache_read_input_tokens", 0)
"completion": 0, "total": 0, "failed": 0,
"cache_read": 0, "cache_created": 0
}
model_totals[model_name]["spend"] += m_metrics.get("spend", 0)
model_totals[model_name]["requests"] += m_metrics.get("successful_requests", 0)
model_totals[model_name]["failed"] += m_metrics.get("failed_requests", 0)
model_totals[model_name]["prompt"] += m_metrics.get("prompt_tokens", 0)
model_totals[model_name]["completion"] += m_metrics.get("completion_tokens", 0)
model_totals[model_name]["total"] += m_metrics.get("total_tokens", 0)
model_totals[model_name]["cache_read"] += m_metrics.get("cache_read_input_tokens", 0)
model_totals[model_name]["cache_created"] += m_metrics.get("cache_creation_input_tokens", 0)
# Build per-date breakdown lines.append(f"\n{'Model':32s} | {'Spend':>10s} | {'Requests':>12s} | {'Tokens':>12s}")
date_lines = [] lines.append("-" * 85)
for day in results: hidden = 0
date = day.get("date", "?") for name, mt in sorted(model_totals.items(), key=lambda x: x[1]["spend"], reverse=True):
m = day.get("metrics", {})
date_lines.append(
f" {date}: ${m.get('spend', 0):.2f} | "
f"{m.get('successful_requests', 0)} ok, {m.get('failed_requests', 0)} fail | "
f"{m.get('total_tokens', 0):,} tokens"
)
# Build model lines sorted by spend
model_lines = []
hidden_count = 0
for model_name in sorted(model_totals, key=lambda k: model_totals[k]["spend"], reverse=True):
mt = model_totals[model_name]
if effective_hide and mt["spend"] < 0.001: if effective_hide and mt["spend"] < 0.001:
hidden_count += 1 hidden += 1; continue
continue c_inf = f" 💾{int(mt['cache']/(mt['tokens'] or 1)*100)}%" if mt["cache"] else ""
# Strip common provider prefixes for readability lines.append(f" {self._compact_name(name, 30):30s} | ${mt['spend']:9.2f} | {mt['reqs']:8d} ok | {self._format_tokens(mt['tokens']):>10s}{c_inf}")
display = model_name if hidden: lines.append(f" ... ({hidden} models with sub-$0.001 spend hidden)")
for prefix in ("gemini/", "openai/", "anthropic/", "mistral/", "cohere/"):
if display.startswith(prefix):
display = display[len(prefix):]
break
# Truncate long model names to fit table, appending ellipsis
max_name = 24
if len(display) > max_name:
display = display[:max_name - 1] + "…"
# Build token string with split and cache indicator
p_tokens = mt["prompt"]
c_tokens = mt["completion"]
# Compact token numbers
p_str = f"{p_tokens / 1000:.1f}k" if p_tokens >= 1000 else str(p_tokens)
c_str = f"{c_tokens / 1000:.1f}k" if c_tokens >= 1000 else str(c_tokens)
token_display = f"{p_str}→{c_str}"
# Cache indicator
cache_info = ""
if mt["cache_read"] > 0:
hit_rate = (mt["cache_read"] / p_tokens * 100) if p_tokens > 0 else 0
cache_info = f" 💾{hit_rate:.0f}%"
model_lines.append(
f" {display:25s} ${mt['spend']:7.2f} | "
f"{mt['requests']:3d} ok, {mt['failed']:d} fail | "
f"{token_display:>10s}{cache_info}"
)
# Assemble report
lines = []
lines.append(f"LiteLLM Cost Report: {resolved_start} to {resolved_end}")
lines.append("=" * 70)
lines.append(f"Total Spend: ${total_spend:.2f}")
lines.append(f"Total Tokens: {total_tokens:,} ({total_prompt:,} prompt + {total_completion:,} completion)")
lines.append(f"Cache Hit Rate: {cache_str}")
lines.append(f"Requests: {total_successful} successful, {total_failed} failed ({total_api} total)")
if cache_created > 0:
lines.append(f"Cache Created: {cache_created:,} tokens")
lines.append("")
lines.append("By Model:")
lines.append(f' {"Model":25s} {"Spend":>7s} | {"Requests":>14s} | {"Tokens":>10s}')
lines.extend(model_lines)
if hidden_count > 0:
lines.append(f" ... ({hidden_count} model(s) with sub-$0.001 spend hidden)")
lines.append("")
lines.append("By Date:")
lines.extend(date_lines)
return "\n".join(lines) return "\n".join(lines)
def litellm_history( def litellm_history(self, limit: int = 20, show_failed: bool = True) -> str:
self, """Deep forensic history including Thought detection, Latency, and Cost Audit."""
limit: int = 20, if not self.valves.litellm_master_key: return "❌ Master key required."
show_cost: bool = True,
show_failed: bool = True,
) -> str:
"""
Get recent LiteLLM proxy request history.
:param limit: Number of recent requests to show (1-50, default 20)
:param show_cost: Include per-request cost (default True)
:param show_failed: Include failed requests in output (default True). Set False to hide auth failures, routing errors, etc.
Returns a formatted table of recent LLM requests with model, status, tokens, duration, and optional cost.
"""
if not self.valves.litellm_master_key:
return "❌ Master key required for request history. Set the litellm_master_key valve."
limit = max(1, min(50, limit))
url = f"{self._base_url()}/spend/logs"
headers = self._headers()
try: try:
resp = requests.get(url, headers=headers, timeout=30) resp = requests.get(f"{self._base_url()}/spend/logs", headers=self._headers(), timeout=30)
except requests.exceptions.ConnectionError: if resp.status_code != 200: return f"❌ Error {resp.status_code}"
return f"❌ Connection failed — LiteLLM proxy not reachable at {self._base_url()}" logs = sorted(resp.json(), key=lambda x: x.get("startTime", ""), reverse=True)
except requests.exceptions.Timeout: except Exception as e: return f"❌ Failed: {str(e)}"
return f"❌ Connection timed out after 30s — request history may be large"
if resp.status_code == 401:
return "❌ Authentication failed — check your litellm_master_key valve."
if resp.status_code == 403:
return "❌ Forbidden — your key doesn't have permission to access request logs"
if resp.status_code != 200:
return f"❌ Unexpected status {resp.status_code}: {resp.text[:500]}"
data = resp.json()
if not isinstance(data, list) or len(data) == 0:
return "No request history found."
# Sort by startTime descending and take the most recent `limit` entries
sorted_logs = sorted(data, key=lambda e: e.get("startTime", ""), reverse=True)
# Filter out failed requests if show_failed is False
if not show_failed: if not show_failed:
sorted_logs = [ logs = [l for l in logs if not l.get("metadata", {}).get("exception")]
e for e in sorted_logs
if not (
(e.get("metadata", {}) or {}).get("exception", "")
or (e.get("metadata", {}) or {}).get("status", "") == "failure"
)
]
recent = sorted_logs[:limit] recent = logs[:max(1, min(50, limit))]
total_count = len(data) lines = [f"LiteLLM Forensic History — {self._base_url()}"]
filtered_note = " (failed requests hidden)" if not show_failed else "" lines.append(f"Showing {len(recent)} of {len(logs)} total requests")
lines.append("=" * 115)
lines = [] lines.append(f" # {'Time':8s} | {'Model':28s} | {'Status':7s} | {'Dur':>6s} | {'Spend':>10s} | {'Tokens':>12s}")
lines.append(f"LiteLLM Request History — {self._base_url()}") lines.append("-" * 115)
lines.append(f"Showing {len(recent)} of {total_count} total requests{filtered_note} (most recent first)")
lines.append("=" * 90)
for i, entry in enumerate(recent, 1): for i, entry in enumerate(recent, 1):
# Timestamp ts = entry.get("startTime", "?")[5:16]
start_time = entry.get("startTime", "?")[:19] # YYYY-MM-DDTHH:MM:SS model = self._compact_name(entry.get("model", "unknown"), 28)
start_short = start_time[5:] if len(start_time) > 5 else start_time # MM-DDTHH:MM:SS spend = entry.get("spend", 0)
p_tok = entry.get("prompt_tokens", 0) or 0
c_tok = entry.get("completion_tokens", 0) or 0
# Model — strip provider prefix for readability meta = entry.get("metadata", {}) or {}
model = entry.get("model", "unknown") is_failed = bool(meta.get("exception")) or meta.get("status") == "failure"
for prefix in ("gemini/", "openai/", "anthropic/", "mistral/", "cohere/"):
if model.startswith(prefix):
model = model[len(prefix):]
break
# Call type
call_type = entry.get("call_type", "?")
# Shorten common call types
call_type = call_type.replace("acompletion", "chat").replace("aembedding", "embed").replace("aimage_generation", "image").replace("_", " ")
# Status — check for errors
metadata = entry.get("metadata", {}) or {}
exception = metadata.get("exception", "")
status_flag = metadata.get("status", "")
is_failed = bool(exception) or status_flag == "failure"
status = "❌ FAIL" if is_failed else "✅ OK" status = "❌ FAIL" if is_failed else "✅ OK"
# Duration dur = entry.get("request_duration_ms", 0) or 0
duration_ms = entry.get("request_duration_ms", 0) or 0 dur_s = f"{(dur/1000):.1f}s" if dur > 1000 else f"{dur}ms"
if duration_ms >= 1000: slow = "⚠️" if dur > 15000 else ""
duration_str = f"{duration_ms / 1000:.1f}s"
else:
duration_str = f"{duration_ms}ms"
# Tokens usage = entry.get("usage", {}) or {}
prompt_tokens = entry.get("prompt_tokens", 0) or 0 thought = usage.get("completion_tokens_details", {}).get("reasoning_tokens", 0)
completion_tokens = entry.get("completion_tokens", 0) or 0 thought_s = f" [💭{self._format_tokens(thought)}]" if thought else ""
total_tokens = entry.get("total_tokens", 0) or 0 has_search = bool(meta.get("web_search_options")) or "search" in entry.get("call_type", "")
search_s = " 🔍" if has_search else ""
# Cache extraction (handles Anthropic/OpenAI top-level and Gemini/z.ai nested metadata structures) cached = entry.get("cache_read_input_tokens", 0) or 0
cache_read = entry.get("cache_read_input_tokens", 0) or 0 cache_s = f" 💾{int(cached/p_tok*100)}%" if p_tok and cached else ""
if cache_read == 0:
metadata = entry.get("metadata", {}) or {}
usage_obj = metadata.get("usage_object", {}) or {}
if isinstance(usage_obj, dict):
cache_read = usage_obj.get("cache_read_input_tokens", 0) or 0
if cache_read == 0:
prompt_details = entry.get("prompt_tokens_details", {}) or {}
if isinstance(prompt_details, dict):
cache_read = prompt_details.get("cached_tokens", 0) or 0
# Spend p_f = f"{p_tok/1000:.1f}k" if p_tok >= 1000 else str(p_tok)
spend = entry.get("spend", 0) or 0 c_f = f"{c_tok/1000:.1f}k" if c_tok >= 1000 else str(c_tok)
# Build line audit = self._audit_cost(entry.get("model"), p_tok, cached, c_tok, spend, reasoning=thought, has_search=has_search)
base = f" {i:2d}. {start_short} | {model[:28]:28s} | {call_type[:10]:10s} | {status} | {duration_str:>7s}"
# Token info line = f" {i:2d}. {ts} | {model:28s} | {status} | {dur_s:>6s}{slow} | ${spend:.4f}{audit:18s} | {self._format_tokens(p_tok):>6s}→{self._format_tokens(c_tok):<6s}{thought_s}{cache_s}{search_s}"
if total_tokens > 0: lines.append(line)
# Compact token display if is_failed: lines.append(f" └─ Error: {meta.get('exception', 'unknown')[:85]}")
if prompt_tokens >= 1000:
p_str = f"{prompt_tokens / 1000:.1f}k"
else:
p_str = str(prompt_tokens)
if completion_tokens >= 1000:
c_str = f"{completion_tokens / 1000:.1f}k"
else:
c_str = str(completion_tokens)
token_str = f"{p_str}→{c_str}"
# Cache indicator
if cache_read > 0:
cache_pct = (cache_read / prompt_tokens * 100) if prompt_tokens > 0 else 0
token_str += f" 💾{cache_pct:.0f}%"
base += f" | {token_str}"
# Cost & Audit
if show_cost and spend > 0:
audit = self._audit_cost(entry.get("model", ""), prompt_tokens, cache_read, completion_tokens, spend)
if audit["status"] == "mismatch":
base += f" | ${spend:.4f} (calc: ${audit['audited']:.4f} ⚠️)"
elif audit["status"] == "ok":
base += f" | ${spend:.4f} ✓"
else:
base += f" | ${spend:.4f}"
# Error info
if is_failed:
# Priority: exception string > error_information.error_message > generic status
if exception:
err_short = exception[:80] + "…" if len(exception) > 80 else exception
else:
err_info = metadata.get("error_information", {}) or {}
err_msg = err_info.get("error_message", "")
if err_msg:
err_short = err_msg[:80] + "…" if len(err_msg) > 80 else err_msg
else:
err_short = "status: failure"
base += f"\n └─ {err_short}"
lines.append(base)
# Summary
if len(recent) > 0:
recent_spend = sum(e.get("spend", 0) or 0 for e in recent)
recent_prompt = sum(e.get("prompt_tokens", 0) or 0 for e in recent)
recent_completion = sum(e.get("completion_tokens", 0) or 0 for e in recent)
recent_cache = sum(e.get("cache_read_input_tokens", 0) or 0 for e in recent)
recent_failed = sum(1 for e in recent if ((e.get("metadata", {}) or {}).get("exception")) or ((e.get("metadata", {}) or {}).get("status", "")) == "failure")
lines.append("")
lines.append(f" Summary ({len(recent)} shown): ${recent_spend:.4f} spent | "
f"{recent_prompt:,} prompt + {recent_completion:,} completion tokens | "
f"{recent_failed} failed | "
f"cache hit {recent_cache / recent_prompt * 100:.0f}%" if recent_prompt > 0 and recent_cache > 0
else f" Summary ({len(recent)} shown): ${recent_spend:.4f} spent | "
f"{recent_prompt:,} prompt + {recent_completion:,} completion tokens | "
f"{recent_failed} failed")
return "\n".join(lines) return "\n".join(lines)