Compare commits

...
3 changed files with 380 additions and 483 deletions

No files matched your search

+5
View File
@@ -1 +1,6 @@
__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
author: Kagamine Rin
author: Kagamine Rin & VAI
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
requirements: requests
version: 1.3.2
version: 1.6.4
licence: MIT
"""
import requests
import json
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from pydantic import BaseModel, Field
from typing import Optional
from typing import Optional, List, Dict, Any
class Tools:
def __init__(self):
self.valves = self.Valves()
# TASK-004: Hardcore FinOps Audit Registry (Standard Tier Pricing)
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}
self._price_cache = {}
self._model_info_cache = []
class Valves(BaseModel):
"""LiteLLM proxy connection settings."""
litellm_url: str = Field(
default="http://localhost:4000",
description="LiteLLM proxy base URL (e.g. http://localhost:4000)",
description="LiteLLM proxy base URL",
)
litellm_master_key: str = Field(
default="",
@@ -83,512 +36,320 @@ class Tools:
)
default_hide_zero_spend: bool = Field(
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(
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:
"""Build auth headers from valves."""
if not self.valves.litellm_master_key:
return {}
return {"Authorization": f"Bearer {self.valves.litellm_master_key}"}
def _base_url(self) -> str:
"""Get base URL, strip trailing slash."""
return self.valves.litellm_url.rstrip("/")
def _resolve_date(self, date_str: str) -> str:
"""Resolve natural language date shortcuts to YYYY-MM-DD.
def _compact_name(self, name: str, max_len: int = 28) -> str:
"""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,
monday-sunday (this week), last monday-last sunday,
this week, last week, this month, last month.
Falls back to passthrough if already YYYY-MM-DD or unrecognized.
"""
def _format_tokens(self, n: int) -> str:
"""Scale tokens to human-readable units (k, M, B)."""
if n >= 1_000_000_000:
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()
# Resolve "today" in the configured timezone
try:
today = datetime.now(ZoneInfo(self.valves.timezone)).date()
except Exception:
# Fallback to UTC if timezone is invalid
today = datetime.utcnow().date()
# Direct shortcuts
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 date_str == "today": return today.isoformat()
if date_str == "yesterday": return (today - timedelta(days=1)).isoformat()
if "days ago" in date_str:
try:
n = int(date_str.split()[0])
return (today - timedelta(days=n)).isoformat()
except (ValueError, IndexError):
pass
# Day names — most recent occurrence (including today if it matches)
except Exception: pass
days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
if date_str in days:
target_day = days.index(date_str)
days_back = (today.weekday() - target_day) % 7
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:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
pass
# Unknown — return as-is (API will reject with clear error)
except ValueError: pass
return date_str
def litellm_health(self) -> str:
"""
Check LiteLLM proxy health and authentication status.
Returns which models are healthy/unhealthy, their rate limits, and any auth errors.
Use this to verify your LiteLLM proxy is up and your master key works.
"""
url = f"{self._base_url()}/health"
headers = self._headers()
"""Check LiteLLM proxy health, proactive budget, and grouped model fleet expansion."""
self._fetch_live_data()
lines = [f"LiteLLM Health & Budget — {self._base_url()}"]
lines.append("=" * 70)
# 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:
resp = requests.get(url, headers=headers, timeout=15)
except requests.exceptions.ConnectionError:
return f"❌ Connection failed — LiteLLM proxy not reachable at {self._base_url()}"
except requests.exceptions.Timeout:
return f"❌ Connection timed out after 15s — proxy at {self._base_url()} may be overloaded"
r = requests.get(f"{self._base_url()}/health/readiness", timeout=5)
if r.status_code == 200:
d = r.json()
lines.append(f"✅ Proxy: {d.get('status')} | DB: {d.get('db')} | v{d.get('litellm_version')}")
except Exception: pass
if resp.status_code == 401:
return "❌ Authentication failed — check your litellm_master_key valve. The proxy rejected the key."
if resp.status_code == 403:
return "❌ Forbidden — your key doesn't have permission to access /health"
if resp.status_code != 200:
return f"❌ Unexpected status {resp.status_code}: {resp.text[:500]}"
# 3. Model Fleet (Grouped by Provider + Wildcard Expansion)
try:
url = f"{self._base_url()}/health"
resp = requests.get(url, headers=self._headers(), timeout=15)
if resp.status_code == 200:
hd = resp.json()
healthy = hd.get("healthy_endpoints", [])
unhealthy = hd.get("unhealthy_endpoints", [])
data = resp.json()
healthy = data.get("healthy_endpoints", [])
unhealthy = data.get("unhealthy_endpoints", [])
healthy_count = data.get("healthy_count", len(healthy))
unhealthy_count = data.get("unhealthy_count", len(unhealthy))
groups = {}
for ep in healthy + unhealthy:
m_raw = ep.get("model", "unknown")
api_base = ep.get("api_base", "")
# Strip litellm_metadata — it's ~2KB per model of auth context we don't need
# Also strip other internal fields that bloat the response
STRIP_KEYS = {
"litellm_metadata", "safety_settings", "cache_control_injection_points",
"use_in_pass_through", "use_litellm_proxy", "merge_reasoning_content_in_choices",
}
# Wildcard & Fleet Discovery
m_display = self._compact_name(m_raw, 32)
fleet_members = []
lines = []
lines.append(f"LiteLLM Health Check — {self._base_url()}")
lines.append("=" * 50)
# Search for wildcard/alias labels
target_id = None
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:
lines.append("⚠️ No master key configured — showing public health only")
else:
lines.append("✅ Authenticated with master key")
if not target_id: # Direct match fallback
for info in self._model_info_cache:
if info.get("model_name") == m_raw:
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:
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}")
icon = "✅" if ep in healthy else "❌"
if unhealthy:
lines.append(f"\n--- Unhealthy Models ({unhealthy_count}) ---")
for ep in unhealthy:
model = ep.get("model", "unknown")
display = model.replace("gemini/", "") if model.startswith("gemini/") else model
lines.append(f" ❌ {display}")
# Provider Determination
provider = "other"
if "ollama" in api_base.lower() or "ollama" in m_raw.lower():
provider = "ollama"
elif "/" in m_raw:
provider = m_raw.split("/")[0]
if not healthy and not unhealthy:
lines.append("\n(no endpoint data returned)")
if provider not in groups: groups[provider] = {"ok": [], "fail": []}
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)
def litellm_cost(
self,
start_date: str,
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)
def litellm_cost(self, start_date: str, end_date: str, hide_zero_spend: bool = False) -> str:
"""Comprehensive cost report with Billion-scale token tracking and sparklines."""
if not self.valves.litellm_master_key: return "❌ Master key required."
effective_hide = hide_zero_spend or self.valves.default_hide_zero_spend
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}
r_start, r_end = self._resolve_date(start_date), self._resolve_date(end_date)
try:
resp = requests.get(url, headers=headers, params=params, timeout=30)
except requests.exceptions.ConnectionError:
return f"❌ Connection failed — LiteLLM proxy not reachable at {self._base_url()}"
except requests.exceptions.Timeout:
return f"❌ Connection timed out after 30s — large date ranges may be slow"
url = f"{self._base_url()}/user/daily/activity"
resp = requests.get(url, headers=self._headers(), params={"start_date": r_start, "end_date": r_end}, timeout=30)
if resp.status_code != 200: return f"❌ Error {resp.status_code}"
data = resp.json()
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", [])
metadata = data.get("metadata", {})
if not results: return f"No cost data found for {r_start} to {r_end}."
if not results:
return f"No cost data found for {resolved_start} to {resolved_end}."
spends = [day.get("metrics", {}).get("spend", 0) for day in results]
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
total_spend = metadata.get("total_spend", 0)
total_prompt = metadata.get("total_prompt_tokens", 0)
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)
lines = [f"LiteLLM Cost Report: {r_start} to {r_end}"]
lines.append("=" * 85)
lines.append(f"Total Spend: ${metadata.get('total_spend', 0):.2f}{get_spark(spends)}")
# Cache hit rate
total_input_tokens = total_prompt # cache_read is a subset of prompt
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"
t_in, t_out = metadata.get('total_prompt_tokens',0), metadata.get('total_completion_tokens',0)
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)")
# 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 = {}
for day in results:
breakdown = day.get("breakdown", {})
models = breakdown.get("models", {})
for model_name, model_data in models.items():
m_metrics = model_data.get("metrics", {})
if model_name not in model_totals:
model_totals[model_name] = {
"spend": 0, "requests": 0, "prompt": 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)
for m_name, m_data in day.get("breakdown", {}).get("models", {}).items():
m = m_data.get("metrics", {})
if m_name not in model_totals: model_totals[m_name] = {"spend": 0, "reqs": 0, "tokens": 0, "cache": 0}
model_totals[m_name]["spend"] += m.get("spend", 0)
model_totals[m_name]["reqs"] += m.get("successful_requests", 0)
model_totals[m_name]["tokens"] += m.get("total_tokens", 0)
model_totals[m_name]["cache"] += m.get("cache_read_input_tokens", 0)
# Build per-date breakdown
date_lines = []
for day in results:
date = day.get("date", "?")
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]
lines.append(f"\n{'Model':32s} | {'Spend':>10s} | {'Requests':>12s} | {'Tokens':>12s}")
lines.append("-" * 85)
hidden = 0
for name, mt in sorted(model_totals.items(), key=lambda x: x[1]["spend"], reverse=True):
if effective_hide and mt["spend"] < 0.001:
hidden_count += 1
continue
# Strip common provider prefixes for readability
display = model_name
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)
hidden += 1; continue
c_inf = f" 💾{int(mt['cache']/(mt['tokens'] or 1)*100)}%" if mt["cache"] else ""
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}")
if hidden: lines.append(f" ... ({hidden} models with sub-$0.001 spend hidden)")
return "\n".join(lines)
def litellm_history(
self,
limit: int = 20,
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()
def litellm_history(self, limit: int = 20, show_failed: bool = True) -> str:
"""Deep forensic history including Thought detection, Latency, and Cost Audit."""
if not self.valves.litellm_master_key: return "❌ Master key required."
try:
resp = requests.get(url, headers=headers, timeout=30)
except requests.exceptions.ConnectionError:
return f"❌ Connection failed — LiteLLM proxy not reachable at {self._base_url()}"
except requests.exceptions.Timeout:
return f"❌ Connection timed out after 30s — request history may be large"
resp = requests.get(f"{self._base_url()}/spend/logs", headers=self._headers(), timeout=30)
if resp.status_code != 200: return f"❌ Error {resp.status_code}"
logs = sorted(resp.json(), key=lambda x: x.get("startTime", ""), reverse=True)
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 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:
sorted_logs = [
e for e in sorted_logs
if not (
(e.get("metadata", {}) or {}).get("exception", "")
or (e.get("metadata", {}) or {}).get("status", "") == "failure"
)
]
logs = [l for l in logs if not l.get("metadata", {}).get("exception")]
recent = sorted_logs[:limit]
total_count = len(data)
filtered_note = " (failed requests hidden)" if not show_failed else ""
lines = []
lines.append(f"LiteLLM Request History — {self._base_url()}")
lines.append(f"Showing {len(recent)} of {total_count} total requests{filtered_note} (most recent first)")
lines.append("=" * 90)
recent = logs[:max(1, min(50, limit))]
lines = [f"LiteLLM Forensic History — {self._base_url()}"]
lines.append(f"Showing {len(recent)} of {len(logs)} total requests")
lines.append("=" * 115)
lines.append(f" # {'Time':8s} | {'Model':28s} | {'Status':7s} | {'Dur':>6s} | {'Spend':>10s} | {'Tokens':>12s}")
lines.append("-" * 115)
for i, entry in enumerate(recent, 1):
# Timestamp
start_time = entry.get("startTime", "?")[:19] # YYYY-MM-DDTHH:MM:SS
start_short = start_time[5:] if len(start_time) > 5 else start_time # MM-DDTHH:MM:SS
ts = entry.get("startTime", "?")[5:16]
model = self._compact_name(entry.get("model", "unknown"), 28)
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
model = entry.get("model", "unknown")
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"
meta = entry.get("metadata", {}) or {}
is_failed = bool(meta.get("exception")) or meta.get("status") == "failure"
status = "❌ FAIL" if is_failed else "✅ OK"
# Duration
duration_ms = entry.get("request_duration_ms", 0) or 0
if duration_ms >= 1000:
duration_str = f"{duration_ms / 1000:.1f}s"
else:
duration_str = f"{duration_ms}ms"
dur = entry.get("request_duration_ms", 0) or 0
dur_s = f"{(dur/1000):.1f}s" if dur > 1000 else f"{dur}ms"
slow = "⚠️" if dur > 15000 else ""
# Tokens
prompt_tokens = entry.get("prompt_tokens", 0) or 0
completion_tokens = entry.get("completion_tokens", 0) or 0
total_tokens = entry.get("total_tokens", 0) or 0
usage = entry.get("usage", {}) or {}
thought = usage.get("completion_tokens_details", {}).get("reasoning_tokens", 0)
thought_s = f" [💭{self._format_tokens(thought)}]" if thought else ""
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)
cache_read = entry.get("cache_read_input_tokens", 0) or 0
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
cached = entry.get("cache_read_input_tokens", 0) or 0
cache_s = f" 💾{int(cached/p_tok*100)}%" if p_tok and cached else ""
# Spend
spend = entry.get("spend", 0) or 0
p_f = f"{p_tok/1000:.1f}k" if p_tok >= 1000 else str(p_tok)
c_f = f"{c_tok/1000:.1f}k" if c_tok >= 1000 else str(c_tok)
# Build line
base = f" {i:2d}. {start_short} | {model[:28]:28s} | {call_type[:10]:10s} | {status} | {duration_str:>7s}"
audit = self._audit_cost(entry.get("model"), p_tok, cached, c_tok, spend, reasoning=thought, has_search=has_search)
# Token info
if total_tokens > 0:
# Compact token display
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")
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}"
lines.append(line)
if is_failed: lines.append(f" └─ Error: {meta.get('exception', 'unknown')[:85]}")
return "\n".join(lines)