From c22457574f8ff29d61c9f8e801c9ed6ce5f7d7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Sat, 18 Apr 2026 20:03:12 +0200 Subject: [PATCH] feat(quotas): group by provider, gate on credentials, add skipped view Reworks the /api/quotas surface so the dashboard widget matches how CodexBar presents provider state: - QuotaStatus gains a provider_group field; the widget groups packages under one provider card and subdivides by package inside it - daily / rolling_window statuses accept extra_provider_ids so a shared quota (e.g. Google AI Studio free tier covers both gemini-flash and gemini-flash-lite) is counted across all router IDs that draw from it - /api/quotas applies a credential gate: packages tagged with _requires_credential are hidden when the env var is missing / placeholder, or when the OAuth subject is not in the local token store. Skipped entries are reported back under skipped_packages so operators can see what's dormant and why. - dashboard widget renders one card per provider_group with all its packages stacked (CodexBar-style), plus a small "hidden" callout listing credential-gated packages that aren't active yet --- faigate/main.py | 182 +++++++++++++++++++++++++++++++++------ faigate/quota_tracker.py | 60 ++++++++++--- 2 files changed, 205 insertions(+), 37 deletions(-) diff --git a/faigate/main.py b/faigate/main.py index 2aba15d..757a75a 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -2827,16 +2827,75 @@ async def operator_events( } +def _credential_available(hint: str | None) -> bool: + """True when ``hint`` names a credential that's actually present. + + Accepts two shapes: + * env-var name (ALL_CAPS): looked up in ``os.environ``; values that + contain ``your-`` or ``your_`` are treated as placeholders. + * OAuth subject: looked up in the local token store's provider list. + + ``None`` / empty hints pass through as "no gating required" → True. + """ + if not hint: + return True + value = str(hint).strip() + if not value: + return True + # env-var heuristic: all-caps + underscores, no hyphens + if value.replace("_", "").isalnum() and value.isupper(): + raw = os.environ.get(value) + if not raw: + return False + low = raw.lower() + if "your-" in low or "your_" in low or low.endswith("-key"): + return False + return True + # OAuth subject: consult token store + try: + from .oauth.token_store import TokenStore + + providers = TokenStore().list_providers() + return value in providers + except Exception: # noqa: BLE001 + return False + + +def _filter_packages_by_credentials( + packages: dict[str, dict[str, Any]], +) -> tuple[dict[str, dict[str, Any]], list[dict[str, str]]]: + """Return (kept, skipped) after applying ``_requires_credential`` gating.""" + kept: dict[str, dict[str, Any]] = {} + skipped: list[dict[str, str]] = [] + for pkg_id, pkg in packages.items(): + hint = pkg.get("_requires_credential") + if _credential_available(hint): + kept[pkg_id] = pkg + else: + skipped.append( + { + "package_id": pkg_id, + "provider_group": str(pkg.get("provider_group") or ""), + "requires": str(hint or ""), + } + ) + return kept, skipped + + @app.get("/api/quotas") async def quotas(): """Unified view across all quota packages (credits / rolling / daily). Returns the QuotaStatus list the dashboard renders as progress bars plus - the latest header-capture snapshot per provider (diagnostic). Never - errors: missing catalog / SQLite path → empty lists. + the latest header-capture snapshot per provider (diagnostic). Packages + whose ``_requires_credential`` cannot be resolved (missing env var, + placeholder value, missing OAuth token) are skipped and reported under + ``skipped_packages``. Never errors: missing catalog / SQLite path → + empty lists. """ from pathlib import Path + from .provider_catalog import get_packages_catalog from .quota_headers import all_latest_snapshots from .quota_tracker import compute_all_statuses @@ -2849,7 +2908,15 @@ async def quotas(): sqlite_path = None try: - statuses = compute_all_statuses(sqlite_path=sqlite_path) + raw_packages = get_packages_catalog() or {} + except Exception as exc: # noqa: BLE001 + logger.warning("get_packages_catalog failed: %s", exc) + raw_packages = {} + + filtered_packages, skipped_packages = _filter_packages_by_credentials(raw_packages) + + try: + statuses = compute_all_statuses(sqlite_path=sqlite_path, packages_cache=filtered_packages) except Exception as exc: # noqa: BLE001 logger.warning("compute_all_statuses failed: %s", exc) statuses = [] @@ -2882,6 +2949,7 @@ async def quotas(): "has_use_or_lose": any(s.get("alert") == "use_or_lose" for s in statuses_json), "has_exhausted": any(s.get("alert") == "exhausted" for s in statuses_json), "header_snapshots": snapshots_out, + "skipped_packages": skipped_packages, } @@ -3377,22 +3445,39 @@ async def dashboard(): background: var(--card); border: 1px solid var(--border); } .pill.urgent { border-color: var(--uol); color: var(--uol); } - .grid { display: grid; gap: 12px; grid-template-columns: 1fr; max-width: 980px; } - .card { + .grid { display: grid; gap: 14px; grid-template-columns: 1fr; max-width: 980px; } + .provider { background: var(--card); border: 1px solid var(--border); - border-radius: 8px; padding: 14px 16px; + border-radius: 10px; padding: 14px 16px; + } + .provider.urgent { border-left: 3px solid var(--uol); } + .provider.watch { border-left: 3px solid var(--watch); } + .provider.ok { border-left: 3px solid var(--ok); } + .provider.topup { border-left: 3px solid var(--topup); } + .provider.exhausted { border-left: 3px solid var(--exhausted); opacity: 0.85; } + .provider-head { + display: flex; justify-content: space-between; align-items: baseline; + margin-bottom: 4px; } - .card.urgent { border-left: 3px solid var(--uol); } - .card.watch { border-left: 3px solid var(--watch); } - .card.ok { border-left: 3px solid var(--ok); } - .card.topup { border-left: 3px solid var(--topup); } - .card.exhausted { border-left: 3px solid var(--exhausted); opacity: 0.7; } + .provider-name { + font-weight: 700; font-size: 15px; text-transform: capitalize; + letter-spacing: .3px; + } + .provider-ids { color: var(--dim); font-size: 11px; } + .pkg { + padding: 10px 0 2px; + border-top: 1px dashed var(--border); + margin-top: 8px; + } + .pkg:first-of-type { border-top: none; margin-top: 0; padding-top: 2px; } .row1 { display: flex; justify-content: space-between; align-items: baseline; } - .title { font-weight: 600; font-size: 14px; } - .type { color: var(--dim); font-size: 11px; text-transform: uppercase; } + .title { font-weight: 500; font-size: 13px; } + .title .emoji { margin-right: 4px; } + .title .pkg-id { color: var(--dim); font-weight: 400; } + .type { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: .5px; } .bar { height: 6px; background: #262a36; border-radius: 3px; - margin: 8px 0 6px; overflow: hidden; + margin: 6px 0 6px; overflow: hidden; } .bar-fill { height: 100%; transition: width .3s; } .bar-fill.ok { background: var(--ok); } @@ -3401,11 +3486,11 @@ async def dashboard(): .bar-fill.use_or_lose { background: var(--uol); } .bar-fill.exhausted { background: var(--exhausted); } .meta { - display: flex; gap: 16px; flex-wrap: wrap; - font-size: 12px; color: var(--dim); + display: flex; gap: 14px; flex-wrap: wrap; + font-size: 11.5px; color: var(--dim); } .meta .k { color: var(--fg); font-weight: 500; } - .notes { margin-top: 6px; font-size: 11px; color: var(--dim); font-style: italic; } + .notes { margin-top: 4px; font-size: 10.5px; color: var(--dim); font-style: italic; } .empty { padding: 40px; text-align: center; color: var(--dim); } a { color: #60a5fa; text-decoration: none; } a:hover { text-decoration: underline; } @@ -3419,9 +3504,11 @@ async def dashboard():
Loading…
+