Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8988b9c4e | ||
|
|
b84cf34e88 | ||
|
|
9490a66451 | ||
|
|
5cdfc80f47 | ||
|
|
49b95b1f62 | ||
|
|
6359a2b2a5 | ||
|
|
6add0680ac | ||
|
|
e63af354ec | ||
|
|
e60c74c182 | ||
|
|
70d46c64dd | ||
|
|
289ca15475 |
No files matched your search
@@ -1 +1,6 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
secret_key.txt
|
||||||
|
integration_key.txt
|
||||||
|
test_results.xml
|
||||||
@@ -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.
|
||||||
+245
-484
@@ -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:
|
try:
|
||||||
resp = requests.get(url, headers=headers, timeout=15)
|
r = requests.get(f"{self._base_url()}/spend/keys", headers=self._headers(), timeout=10)
|
||||||
except requests.exceptions.ConnectionError:
|
if r.status_code == 200:
|
||||||
return f"❌ Connection failed — LiteLLM proxy not reachable at {self._base_url()}"
|
for k in r.json():
|
||||||
except requests.exceptions.Timeout:
|
spend, limit = k.get("spend", 0), k.get("max_budget")
|
||||||
return f"❌ Connection timed out after 15s — proxy at {self._base_url()} may be overloaded"
|
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
|
||||||
|
|
||||||
if resp.status_code == 401:
|
# 2. Infrastructure Readiness
|
||||||
return "❌ Authentication failed — check your litellm_master_key valve. The proxy rejected the key."
|
try:
|
||||||
if resp.status_code == 403:
|
r = requests.get(f"{self._base_url()}/health/readiness", timeout=5)
|
||||||
return "❌ Forbidden — your key doesn't have permission to access /health"
|
if r.status_code == 200:
|
||||||
if resp.status_code != 200:
|
d = r.json()
|
||||||
return f"❌ Unexpected status {resp.status_code}: {resp.text[:500]}"
|
lines.append(f"✅ Proxy: {d.get('status')} | DB: {d.get('db')} | v{d.get('litellm_version')}")
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
data = resp.json()
|
# 3. Model Fleet (Grouped by Provider + Wildcard Expansion)
|
||||||
healthy = data.get("healthy_endpoints", [])
|
try:
|
||||||
unhealthy = data.get("unhealthy_endpoints", [])
|
url = f"{self._base_url()}/health"
|
||||||
healthy_count = data.get("healthy_count", len(healthy))
|
resp = requests.get(url, headers=self._headers(), timeout=15)
|
||||||
unhealthy_count = data.get("unhealthy_count", len(unhealthy))
|
if resp.status_code == 200:
|
||||||
|
hd = resp.json()
|
||||||
|
healthy = hd.get("healthy_endpoints", [])
|
||||||
|
unhealthy = hd.get("unhealthy_endpoints", [])
|
||||||
|
|
||||||
# Strip litellm_metadata — it's ~2KB per model of auth context we don't need
|
groups = {}
|
||||||
# Also strip other internal fields that bloat the response
|
for ep in healthy + unhealthy:
|
||||||
STRIP_KEYS = {
|
m_raw = ep.get("model", "unknown")
|
||||||
"litellm_metadata", "safety_settings", "cache_control_injection_points",
|
api_base = ep.get("api_base", "")
|
||||||
"use_in_pass_through", "use_litellm_proxy", "merge_reasoning_content_in_choices",
|
|
||||||
}
|
|
||||||
|
|
||||||
lines = []
|
# Wildcard & Fleet Discovery
|
||||||
lines.append(f"LiteLLM Health Check — {self._base_url()}")
|
m_display = self._compact_name(m_raw, 32)
|
||||||
lines.append("=" * 50)
|
fleet_members = []
|
||||||
|
|
||||||
if not self.valves.litellm_master_key:
|
# Search for wildcard/alias labels
|
||||||
lines.append("⚠️ No master key configured — showing public health only")
|
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 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
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
icon = "✅" if ep in healthy else "❌"
|
||||||
|
|
||||||
|
# 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 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:
|
else:
|
||||||
lines.append("✅ Authenticated with master key")
|
info_block = f" {icon} {m_display:45s} | rpm: {ep.get('rpm','?'):>3}"
|
||||||
|
|
||||||
lines.append(f"\nHealthy: {healthy_count} | Unhealthy: {unhealthy_count}")
|
if ep in healthy: groups[provider]["ok"].append(info_block)
|
||||||
|
else: groups[provider]["fail"].append(info_block)
|
||||||
|
|
||||||
if healthy:
|
lines.append(f"\nFleet Status: {len(healthy)} healthy, {len(unhealthy)} unhealthy")
|
||||||
lines.append(f"\n--- Healthy Models ({healthy_count}) ---")
|
for prov in sorted(groups.keys()):
|
||||||
for ep in healthy:
|
lines.append(f"\n--- {prov.upper()} ---")
|
||||||
model = ep.get("model", "unknown")
|
lines.extend(groups[prov]["ok"] + groups[prov]["fail"])
|
||||||
# Strip gemini/ prefix for readability
|
else: lines.append(f"\n❌ Fleet Check Failed: Status {resp.status_code}")
|
||||||
display = model.replace("gemini/", "") if model.startswith("gemini/") else model
|
except Exception as e: lines.append(f"\n❌ Fleet Check Failed: {str(e)}")
|
||||||
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:
|
|
||||||
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}")
|
|
||||||
|
|
||||||
if not healthy and not unhealthy:
|
|
||||||
lines.append("\n(no endpoint data returned)")
|
|
||||||
|
|
||||||
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:
|
|
||||||
return f"❌ Connection timed out after 30s — large date ranges may be slow"
|
|
||||||
|
|
||||||
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()
|
data = resp.json()
|
||||||
|
except Exception as e: return f"❌ Failed: {str(e)}"
|
||||||
|
|
||||||
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)
|
||||||
Reference in new issue
Block a user