254 lines
15 KiB
Python
254 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
import os, sqlite3, time, json, datetime, pathlib, threading
|
|
from collections import defaultdict
|
|
from flask import Flask, jsonify, render_template_string
|
|
import requests
|
|
|
|
DATA_DIR=pathlib.Path(os.getenv('DATA_DIR','/var/lib/token-monitor'))
|
|
DB=DATA_DIR/'hermes-state.db'
|
|
ALERT_STATE=DATA_DIR/'alert-state.json'
|
|
# Legacy Hermes-internal limits are kept for backwards-compatible API fields only.
|
|
# Provider stock/remaining budget is calculated in provider_inventory() below.
|
|
DAILY_LIMIT=int(os.getenv('DAILY_TOKEN_LIMIT','0') or 0)
|
|
MONTHLY_LIMIT=int(os.getenv('MONTHLY_TOKEN_LIMIT','0') or 0)
|
|
DISCORD_BOT_TOKEN=os.getenv('DISCORD_BOT_TOKEN','')
|
|
DISCORD_CHANNEL_ID=os.getenv('DISCORD_CHANNEL_ID','')
|
|
REFRESH_SECONDS=int(os.getenv('REFRESH_SECONDS','300'))
|
|
app=Flask(__name__)
|
|
|
|
PROVIDER_LABELS={
|
|
'anthropic':'Anthropic', 'nvidia':'NVIDIA', 'openrouter':'OpenRouter',
|
|
'ollama':'Ollama Cloud', 'openai':'OpenAI', 'openai-codex':'OpenAI/Codex'
|
|
}
|
|
|
|
|
|
def _connect():
|
|
if not DB.exists(): return None
|
|
return sqlite3.connect(f'file:{DB}?mode=ro', uri=True)
|
|
|
|
|
|
def _num_env(name):
|
|
raw=os.getenv(name,'').strip()
|
|
if not raw: return None
|
|
try: return float(raw.replace('_',''))
|
|
except ValueError: return None
|
|
|
|
|
|
def _fmt_amount(value, unit):
|
|
if value is None: return None
|
|
if unit == 'usd':
|
|
return f'${value:,.2f}'
|
|
if unit == 'credits':
|
|
return f'{value:,.2f} Credits'
|
|
return f'{int(value):,} Tokens'.replace(',', '.')
|
|
|
|
|
|
def provider_for_model(model):
|
|
m=(model or '').lower()
|
|
if m.startswith('anthropic/') or m.startswith('claude-') or '/claude-' in m:
|
|
return 'anthropic'
|
|
if m.startswith('nvidia/') or 'nemotron' in m:
|
|
return 'nvidia'
|
|
if m.startswith('openai/') or m.startswith('gpt-') or '/gpt-' in m:
|
|
return 'openai-codex'
|
|
if m.startswith('openrouter/'):
|
|
return 'openrouter'
|
|
if m.startswith('ollama/'):
|
|
return 'ollama'
|
|
return 'unknown'
|
|
|
|
|
|
def usage_by_provider(rows):
|
|
daily=defaultdict(int); monthly=defaultdict(int); total=defaultdict(int)
|
|
now=datetime.datetime.now()
|
|
month_start=now.replace(day=1,hour=0,minute=0,second=0,microsecond=0).timestamp()
|
|
day_start=now.replace(hour=0,minute=0,second=0,microsecond=0).timestamp()
|
|
for r in rows:
|
|
prov=provider_for_model(r['model'])
|
|
tok=int(r['tok'] or 0); ts=float(r['timestamp'] or 0)
|
|
total[prov]+=tok
|
|
if ts>=day_start: daily[prov]+=tok
|
|
if ts>=month_start: monthly[prov]+=tok
|
|
return {'daily':dict(daily), 'monthly':dict(monthly), 'total':dict(total)}
|
|
|
|
|
|
def provider_inventory(usage=None):
|
|
"""Return provider-reported or explicitly configured remaining stock.
|
|
|
|
We intentionally do not treat Hermes message history as the source of truth for
|
|
available quota. It is only used as a subtraction term when a provider-specific
|
|
stock limit was explicitly configured via environment variables.
|
|
"""
|
|
usage=usage or {'daily':{}, 'monthly':{}, 'total':{}}
|
|
providers=[]
|
|
candidates=[]
|
|
if os.getenv('ANTHROPIC_API_KEY') or os.getenv('ANTHROPIC_TOKEN'):
|
|
candidates.append('anthropic')
|
|
if os.getenv('NVIDIA_API_KEY'):
|
|
candidates.append('nvidia')
|
|
if os.getenv('OPENROUTER_API_KEY'):
|
|
candidates.append('openrouter')
|
|
if os.getenv('OLLAMA_API_KEY'):
|
|
candidates.append('ollama')
|
|
for extra in os.getenv('TOKEN_MONITOR_PROVIDERS','').split(','):
|
|
extra=extra.strip().lower()
|
|
if extra and extra not in candidates: candidates.append(extra)
|
|
|
|
for name in candidates:
|
|
unit=os.getenv(f'{name.upper().replace("-","_")}_STOCK_UNIT','tokens').lower()
|
|
entry={
|
|
'provider': name,
|
|
'label': PROVIDER_LABELS.get(name,name),
|
|
'ok': None,
|
|
'status': None,
|
|
'source': 'unavailable',
|
|
'unit': unit,
|
|
'daily_limit': _num_env(f'{name.upper().replace("-","_")}_DAILY_TOKEN_LIMIT'),
|
|
'total_limit': _num_env(f'{name.upper().replace("-","_")}_TOTAL_TOKEN_LIMIT'),
|
|
'daily_used': usage.get('daily',{}).get(name,0),
|
|
'total_used': usage.get('total',{}).get(name,0),
|
|
'daily_remaining': None,
|
|
'total_remaining': None,
|
|
'note': None,
|
|
}
|
|
try:
|
|
if name=='openrouter':
|
|
r=requests.get('https://openrouter.ai/api/v1/credits',headers={'Authorization':'Bearer '+os.getenv('OPENROUTER_API_KEY','')},timeout=8)
|
|
entry.update({'ok':r.status_code<400,'status':r.status_code})
|
|
if r.ok:
|
|
data=r.json().get('data',{})
|
|
credits=float(data.get('total_credits') or 0)
|
|
used=float(data.get('total_usage') or 0)
|
|
entry.update({
|
|
'source':'provider_api', 'unit':'usd',
|
|
'total_limit': credits,
|
|
'total_used': used,
|
|
'total_remaining': max(0.0, credits-used),
|
|
'note':'OpenRouter meldet Gesamt-Credits und Gesamtverbrauch; kein Tagesbestand verfügbar.'
|
|
})
|
|
elif name=='anthropic':
|
|
r=requests.get('https://api.anthropic.com/v1/models',headers={'x-api-key':os.getenv('ANTHROPIC_API_KEY') or os.getenv('ANTHROPIC_TOKEN',''),'anthropic-version':'2023-06-01'},timeout=8)
|
|
entry.update({'ok':r.status_code<400,'status':r.status_code,'note':'Anthropic stellt für normale API-Keys keinen Restbestand/Quota-Endpunkt bereit.'})
|
|
elif name=='nvidia':
|
|
r=requests.get('https://integrate.api.nvidia.com/v1/models',headers={'Authorization':'Bearer '+os.getenv('NVIDIA_API_KEY','')},timeout=8)
|
|
entry.update({'ok':r.status_code<400,'status':r.status_code,'note':'NVIDIA meldet per Models-API nur Erreichbarkeit, keinen Token-Restbestand.'})
|
|
elif name=='ollama':
|
|
r=requests.get('https://ollama.com/v1/models',headers={'Authorization':'Bearer '+os.getenv('OLLAMA_API_KEY','')},timeout=8)
|
|
entry.update({'ok':r.status_code<400,'status':r.status_code,'note':'Ollama Cloud meldet hier nur API-Erreichbarkeit, keinen Restbestand.'})
|
|
except Exception as e:
|
|
entry.update({'ok':False,'error':str(e)[:160]})
|
|
|
|
if entry['daily_limit'] is not None and entry['daily_remaining'] is None:
|
|
entry['daily_remaining']=max(0, entry['daily_limit']-entry['daily_used'])
|
|
entry['source']='configured_limit_minus_hermes_usage'
|
|
if entry['total_limit'] is not None and entry['total_remaining'] is None:
|
|
entry['total_remaining']=max(0, entry['total_limit']-entry['total_used'])
|
|
entry['source']='configured_limit_minus_hermes_usage'
|
|
entry['daily_remaining_display']=_fmt_amount(entry['daily_remaining'], entry['unit'])
|
|
entry['total_remaining_display']=_fmt_amount(entry['total_remaining'], entry['unit'])
|
|
entry['daily_limit_display']=_fmt_amount(entry['daily_limit'], entry['unit'])
|
|
entry['total_limit_display']=_fmt_amount(entry['total_limit'], entry['unit'])
|
|
providers.append(entry)
|
|
return providers
|
|
|
|
|
|
def metrics():
|
|
con=_connect()
|
|
now=datetime.datetime.now()
|
|
month_start=now.replace(day=1,hour=0,minute=0,second=0,microsecond=0).timestamp()
|
|
day_start=now.replace(hour=0,minute=0,second=0,microsecond=0).timestamp()
|
|
out={'updated_at': time.time(), 'daily_limit':DAILY_LIMIT,'monthly_limit':MONTHLY_LIMIT,
|
|
'total_tokens':0,'daily_tokens':0,'monthly_tokens':0,'by_model':[], 'by_day':[], 'by_source':[],
|
|
'recent_sessions':[], 'provider_inventory': [], 'provider_health': []}
|
|
rows=[]
|
|
if not con:
|
|
out['error']='Hermes state DB noch nicht synchronisiert.'
|
|
out['provider_inventory']=provider_inventory()
|
|
out['provider_health']=out['provider_inventory']
|
|
return out
|
|
con.row_factory=sqlite3.Row
|
|
cur=con.cursor()
|
|
cur.execute('''select coalesce(s.model,"unknown") model, coalesce(s.source,"unknown") source, m.timestamp,
|
|
case when m.token_count is not null then m.token_count
|
|
else max(1, cast(length(coalesce(m.content,''))/4 as integer)) end tok
|
|
from messages m join sessions s on s.id=m.session_id''')
|
|
rows=cur.fetchall()
|
|
by_model=defaultdict(int); by_source=defaultdict(int); by_day=defaultdict(int)
|
|
for r in rows:
|
|
tok=int(r['tok'] or 0); ts=float(r['timestamp'] or 0)
|
|
out['total_tokens']+=tok
|
|
by_model[r['model']]+=tok; by_source[r['source']]+=tok
|
|
by_day[datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d')]+=tok
|
|
if ts>=day_start: out['daily_tokens']+=tok
|
|
if ts>=month_start: out['monthly_tokens']+=tok
|
|
usage=usage_by_provider(rows)
|
|
out['provider_inventory']=provider_inventory(usage)
|
|
out['provider_health']=out['provider_inventory'] # backwards-compatible name for old UI/API users
|
|
out['by_provider_usage']=[{'provider':k,'tokens':v} for k,v in sorted(usage['total'].items(), key=lambda x:-x[1])]
|
|
out['by_model']=[{'model':k,'tokens':v} for k,v in sorted(by_model.items(), key=lambda x:-x[1])]
|
|
out['by_source']=[{'source':k,'tokens':v} for k,v in sorted(by_source.items(), key=lambda x:-x[1])]
|
|
out['by_day']=[{'day':k,'tokens':v} for k,v in sorted(by_day.items())[-60:]]
|
|
cur.execute('select id, source, model, started_at, ended_at, message_count from sessions order by started_at desc limit 20')
|
|
out['recent_sessions']=[dict(r) for r in cur.fetchall()]
|
|
return out
|
|
|
|
|
|
def send_discord(msg):
|
|
if not (DISCORD_BOT_TOKEN and DISCORD_CHANNEL_ID): return False
|
|
r=requests.post(f'https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages',headers={'Authorization':'Bot '+DISCORD_BOT_TOKEN,'Content-Type':'application/json'},json={'content':msg},timeout=10)
|
|
return r.status_code<300
|
|
|
|
|
|
def alert_loop():
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
while True:
|
|
try:
|
|
m=metrics(); state={}
|
|
if ALERT_STATE.exists(): state=json.loads(ALERT_STATE.read_text())
|
|
today=datetime.date.today().isoformat(); month=datetime.date.today().strftime('%Y-%m')
|
|
for p in m.get('provider_inventory',[]):
|
|
for scope,period in [('daily',today),('total',month)]:
|
|
rem=p.get(f'{scope}_remaining'); limit=p.get(f'{scope}_limit')
|
|
if rem is None or not limit or limit <= 0: continue
|
|
ratio=rem/limit
|
|
for threshold,label in [(0.2,'unter 20%'),(0.0,'aufgebraucht')]:
|
|
if ratio<=threshold:
|
|
sid=f'provider-stock:{p["provider"]}:{scope}:{period}:{threshold}'
|
|
if not state.get(sid):
|
|
send_discord(f'⚠️ Token-Monitor: {p.get("label",p["provider"])} {scope}-Bestand {label}: {p.get(f"{scope}_remaining_display") or rem} verfügbar')
|
|
state[sid]=time.time()
|
|
ALERT_STATE.write_text(json.dumps(state,indent=2))
|
|
except Exception as e:
|
|
print('alert_loop error',e,flush=True)
|
|
time.sleep(REFRESH_SECONDS)
|
|
|
|
|
|
@app.route('/api/metrics')
|
|
def api_metrics(): return jsonify(metrics())
|
|
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return render_template_string('''<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Model Token Monitor</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>body{font-family:system-ui;background:#0b1020;color:#e5eefc;margin:0}main{max-width:1200px;margin:auto;padding:24px}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.card{background:#111a33;border:1px solid #26375f;border-radius:16px;padding:16px}.muted{color:#9fb0d0}.big{font-size:1.8rem;font-weight:700;margin:.2rem 0}.pill{display:inline-block;border-radius:999px;padding:2px 8px;background:#25375f;margin-left:6px}canvas{background:#111a33;border-radius:16px;padding:12px;margin-top:16px}.ok{color:#56d364}.bad{color:#ff7b72}.unknown{color:#d29922}table{width:100%;border-collapse:collapse}td,th{padding:8px;border-bottom:1px solid #26375f;text-align:left}code{color:#7ee787}</style></head><body><main><h1>Model Token Monitor</h1><p>Bestand der Modell-Anbieter: wie viel heute bzw. insgesamt noch verfügbar ist. Hermes-Verbrauch bleibt darunter nur als Historie sichtbar.</p><div class="cards" id="cards"></div><h2>Anbieter-Bestand</h2><div class="cards" id="providers"></div><h2>Hermes-Nutzungshistorie</h2><canvas id="day"></canvas><canvas id="model"></canvas><h2>Letzte Sessions</h2><div id="sessions"></div></main><script>
|
|
let charts=[];
|
|
function fmt(n){return (n||0).toLocaleString('de-DE')}
|
|
function val(v,fallback='nicht verfügbar'){return (v===null||v===undefined||v==='')?fallback:v}
|
|
function statusClass(p){return p.ok===true?'ok':(p.ok===false?'bad':'unknown')}
|
|
async function load(){let m=await (await fetch('/api/metrics')).json();
|
|
let providers=m.provider_inventory||[];
|
|
let availableDaily=providers.filter(p=>p.daily_remaining_display).length;
|
|
let availableTotal=providers.filter(p=>p.total_remaining_display).length;
|
|
cards.innerHTML=`<div class=card><b>Anbieter geprüft</b><div class=big>${providers.length}</div></div><div class=card><b>Tagesbestand bekannt</b><div class=big>${availableDaily}/${providers.length}</div></div><div class=card><b>Gesamtbestand bekannt</b><div class=big>${availableTotal}/${providers.length}</div></div><div class=card><b>Update</b><div class=big>${new Date(m.updated_at*1000).toLocaleString('de-DE')}</div></div>`;
|
|
providers.innerHTML=providers.map(p=>`<div class=card><b>${p.label||p.provider}</b><span class="pill ${statusClass(p)}">${p.ok===true?'API OK':p.ok===false?'API FEHLER':'Bestand unbekannt'}</span><p class=muted>Quelle: ${p.source||'unbekannt'} ${p.status?`· HTTP ${p.status}`:''}</p><div>Täglich verfügbar</div><div class=big>${val(p.daily_remaining_display)}</div>${p.daily_limit_display?`<p class=muted>Limit ${p.daily_limit_display}, Hermes heute genutzt ${fmt(p.daily_used)}</p>`:''}<div>Insgesamt verfügbar</div><div class=big>${val(p.total_remaining_display)}</div>${p.total_limit_display?`<p class=muted>Limit/Credits ${p.total_limit_display}, genutzt ${p.total_used}</p>`:''}${p.note?`<p class=muted>${p.note}</p>`:''}${p.error?`<code>${p.error}</code>`:''}</div>`).join('')||'<p>Keine Provider-Credentials oder TOKEN_MONITOR_PROVIDERS konfiguriert.</p>';
|
|
charts.forEach(c=>c.destroy()); charts=[];
|
|
charts.push(new Chart(day,{type:'line',data:{labels:m.by_day.map(x=>x.day),datasets:[{label:'Hermes Tokens/Tag (Historie)',data:m.by_day.map(x=>x.tokens),borderColor:'#00d4ff'}]}}));
|
|
charts.push(new Chart(model,{type:'bar',data:{labels:m.by_model.slice(0,12).map(x=>x.model),datasets:[{label:'Hermes Tokens nach Modell',data:m.by_model.slice(0,12).map(x=>x.tokens),backgroundColor:'#00d4ff'}]},options:{indexAxis:'y'}}));
|
|
sessions.innerHTML='<table><tr><th>Zeit</th><th>Quelle</th><th>Modell</th><th>Msgs</th></tr>'+m.recent_sessions.map(s=>`<tr><td>${new Date(s.started_at*1000).toLocaleString('de-DE')}</td><td>${s.source}</td><td>${s.model}</td><td>${s.message_count}</td></tr>`).join('')+'</table>';
|
|
} load(); setInterval(load,300000);
|
|
</script></body></html>''')
|
|
|
|
|
|
if __name__=='__main__':
|
|
threading.Thread(target=alert_loop,daemon=True).start()
|
|
app.run(host='0.0.0.0',port=8080)
|