Initial commit: Agentic OS troubleshooting platform
Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
148
backend/app/services/balance.py
Normal file
148
backend/app/services/balance.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Poll OpenRouter and DeepSeek for credits/balance and store snapshots."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.balance import BalanceSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def fetch_openrouter() -> dict:
|
||||
"""Return {total_credits, total_usage, remaining} for OpenRouter.
|
||||
|
||||
Prefers the provisioning key against /credits (account totals); falls back to
|
||||
/key (per-key limit/usage) when only the regular API key is available.
|
||||
"""
|
||||
key = settings.openrouter_provisioning_key or settings.openrouter_api_key
|
||||
if not key:
|
||||
return {"error": "no OpenRouter key configured"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
# Try account-wide credits first
|
||||
if settings.openrouter_provisioning_key:
|
||||
r = await client.get(
|
||||
"https://openrouter.ai/api/v1/credits",
|
||||
headers={"Authorization": f"Bearer {settings.openrouter_provisioning_key}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json().get("data", {})
|
||||
total = data.get("total_credits")
|
||||
usage = data.get("total_usage")
|
||||
remaining = (total - usage) if total is not None and usage is not None else None
|
||||
return {
|
||||
"total_credits": total,
|
||||
"total_usage": usage,
|
||||
"remaining": remaining,
|
||||
"raw": data,
|
||||
}
|
||||
|
||||
# Fall back to per-key info
|
||||
r = await client.get(
|
||||
"https://openrouter.ai/api/v1/key",
|
||||
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json().get("data", {})
|
||||
limit = data.get("limit")
|
||||
usage = data.get("usage")
|
||||
remaining = data.get("limit_remaining")
|
||||
if remaining is None and limit is not None and usage is not None:
|
||||
remaining = limit - usage
|
||||
return {
|
||||
"total_credits": limit,
|
||||
"total_usage": usage,
|
||||
"remaining": remaining,
|
||||
"raw": data,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_deepseek() -> dict:
|
||||
if not settings.deepseek_api_key:
|
||||
return {"error": "no DeepSeek key configured"}
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
r = await client.get(
|
||||
"https://api.deepseek.com/user/balance",
|
||||
headers={"Authorization": f"Bearer {settings.deepseek_api_key}"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
infos = data.get("balance_infos", [])
|
||||
info = infos[0] if infos else {}
|
||||
total = float(info.get("total_balance")) if info.get("total_balance") else None
|
||||
return {
|
||||
"total_credits": None,
|
||||
"total_usage": None,
|
||||
"remaining": total,
|
||||
"currency": info.get("currency", "USD"),
|
||||
"raw": data,
|
||||
}
|
||||
|
||||
|
||||
async def _store(provider: str, data: dict) -> None:
|
||||
async with SessionLocal() as db:
|
||||
db.add(
|
||||
BalanceSnapshot(
|
||||
provider=provider,
|
||||
total_credits=data.get("total_credits"),
|
||||
total_usage=data.get("total_usage"),
|
||||
remaining=data.get("remaining"),
|
||||
currency=data.get("currency", "USD"),
|
||||
raw=data.get("raw"),
|
||||
error=data.get("error"),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def poll_once() -> None:
|
||||
for provider, fetcher in (("openrouter", fetch_openrouter), ("deepseek", fetch_deepseek)):
|
||||
try:
|
||||
data = await fetcher()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
data = {"error": str(exc)}
|
||||
logger.warning("Balance poll failed for %s: %s", provider, exc)
|
||||
if data.get("error"):
|
||||
# Keep last good snapshot visible when DNS/network blips.
|
||||
async with SessionLocal() as db:
|
||||
res = await db.execute(
|
||||
select(BalanceSnapshot)
|
||||
.where(BalanceSnapshot.provider == provider)
|
||||
.where(BalanceSnapshot.error.is_(None))
|
||||
.where(BalanceSnapshot.remaining.is_not(None))
|
||||
.order_by(BalanceSnapshot.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if res.scalar_one_or_none():
|
||||
logger.info("Skipping error balance snapshot for %s: %s", provider, data["error"])
|
||||
continue
|
||||
await _store(provider, data)
|
||||
|
||||
|
||||
async def latest_remaining_usd() -> float | None:
|
||||
"""Best estimate of remaining USD across configured providers (max)."""
|
||||
async with SessionLocal() as db:
|
||||
remainings: list[float] = []
|
||||
for provider in ("openrouter", "deepseek"):
|
||||
res = await db.execute(
|
||||
select(BalanceSnapshot)
|
||||
.where(BalanceSnapshot.provider == provider)
|
||||
.order_by(BalanceSnapshot.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
snap = res.scalar_one_or_none()
|
||||
if snap and snap.remaining is not None:
|
||||
remainings.append(snap.remaining)
|
||||
return max(remainings) if remainings else None
|
||||
|
||||
|
||||
async def balance_poller_loop() -> None:
|
||||
while True:
|
||||
await poll_once()
|
||||
await asyncio.sleep(settings.balance_poll_interval_seconds)
|
||||
Reference in New Issue
Block a user