Repository navigation
Add Hermes, Antigravity, Z.ai, Ollama Cloud, MiniMax, OpenRouter, and DeepSeek to the agents panel #14039
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dhh
wants to merge
19
commits into
quattro
Choose a base branch
from
agents-hermes-antigravity-providers
base: quattro
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Add Hermes, Antigravity, Z.ai, Ollama Cloud, MiniMax, OpenRouter, and DeepSeek to the agents panel #14039
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
56814ef
Add Hermes usage collector for the agents panel
mbot11 af8c27e
Read Hermes usage from HERMES_HOME and every named profile
dhh d29582b
Add Google Antigravity usage collector
steph4n-gh af92a06
Key Antigravity usage as agy and drop its made-up limits
dhh 60e940d
Add Z.ai GLM Coding Plan agent usage collector
FelipeMayerDev d252480
Mark stale Z.ai limits and mention Z.ai in the manual
dhh 34d02a9
Add Ollama Cloud usage collector to the agents panel
DanteCpp 636bd96
Drop Ollama Cloud's guessed reset times and mark stale limits
dhh 53d4a64
Add MiniMax Token Plan usage collector
McoreD 1df1ff9
Keep stale MiniMax limits and skip MiniMax without a key
dhh c5e5bff
Add OpenRouter usage collector to the agents panel
8e45c3c
Keep a stale OpenRouter key-limit meter and indent with two spaces
dhh 7a1b263
Add a DeepSeek usage collector for the agents panel
aholbreich 52e2412
Skip the DeepSeek record on machines without DeepSeek
dhh 3fda50c
Describe the agents panel without listing every agent
dhh a6f3397
Treat OpenRouter read timeouts as transport failures
dhh 7008b85
Collect OpenRouter usage with only a management key
dhh 5421e2e
Reuse Ollama's session scan only for its day and model filter
dhh 2ffdf9a
Drop cached MiniMax windows that have reset
dhh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,399 @@ | ||
| #!/usr/bin/python3 | ||
| # omarchy:summary=Print the Antigravity usage record as JSON | ||
| # omarchy:args=[--force] [--limits-only] | ||
| # omarchy:hidden=true | ||
| """Collect Google Antigravity usage into one display-ready JSON record. | ||
| The id is agy, the name omarchy-default-agent and omarchy-agent know the CLI | ||
| by, so the record and the default agent agree on which agent this is. | ||
| Local stats come from Antigravity's history log, conversation summaries, and | ||
| conversation transcripts under ~/.gemini/antigravity-cli/. The plan and | ||
| limits come from the Google Cloud Code API (v1internal:loadCodeAssist and | ||
| v1internal:retrieveUserQuotaSummary), asked with the sign-in agy keeps in the | ||
| Secret Service keyring. The agents panel only ever reads the JSON this prints. | ||
| """ | ||
|
|
||
| import argparse | ||
| import base64 | ||
| import json | ||
| import os | ||
| import sqlite3 | ||
| import subprocess | ||
| import tempfile | ||
| import time | ||
| import urllib.error | ||
| import urllib.request | ||
| from datetime import datetime, timedelta, timezone | ||
| from pathlib import Path | ||
|
|
||
| AGENT_ID = "agy" | ||
| AGENT_NAME = "Antigravity" | ||
| AUTH_HELP = "Run `agy` to sign in." | ||
| EXPIRED_HELP = "Antigravity's saved sign-in expired. Start `agy` to refresh it." | ||
| # A panel opened and shut repeatedly reuses an answer for about a minute. | ||
| PROBE_REUSE_SECONDS = 60 | ||
| # A limits-only refresh needs fresh limits, not a fresh history scan. | ||
| LIMITS_ONLY_SCAN_REUSE_SECONDS = 900 | ||
|
|
||
|
|
||
| def app_root(): | ||
| return Path(os.environ.get("AGY_DIR") or (Path.home() / ".gemini" / "antigravity-cli")) | ||
|
|
||
|
|
||
| def cache_root(): | ||
| return Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage" | ||
|
|
||
|
|
||
| def read_json(path): | ||
| try: | ||
| return json.loads(Path(path).read_text(encoding="utf-8")) | ||
| except Exception: | ||
| return None | ||
|
|
||
|
|
||
| # A temporary file of its own per write, so overlapping runs can't rename | ||
| # each other's away. | ||
| def write_json(path, payload): | ||
| path = Path(path) | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) | ||
| try: | ||
| with os.fdopen(fd, "w", encoding="utf-8") as handle: | ||
| json.dump(payload, handle) | ||
| os.replace(tmp, path) | ||
| except Exception: | ||
| Path(tmp).unlink(missing_ok=True) | ||
| raise | ||
|
|
||
|
|
||
| def parse_time(value): | ||
| if value is None or value == "": | ||
| return None | ||
| if isinstance(value, (int, float)) or str(value).isdigit(): | ||
| stamp = float(value) | ||
| return datetime.fromtimestamp(stamp / 1000 if stamp > 1e12 else stamp, timezone.utc) | ||
| try: | ||
| parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) | ||
| except ValueError: | ||
| return None | ||
| return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) | ||
|
|
||
|
|
||
| def local_day(value): | ||
| parsed = parse_time(value) | ||
| return parsed.astimezone().date().isoformat() if parsed else "" | ||
|
|
||
|
|
||
| # The sign-in agy keeps in the keyring: its access token, when that lapses, | ||
| # and which kind of account it is. | ||
| def login(): | ||
| try: | ||
| raw = subprocess.check_output( | ||
| ["secret-tool", "lookup", "service", "gemini", "username", "antigravity"], | ||
| text=True, stderr=subprocess.DEVNULL, timeout=3, | ||
| ) | ||
| data = json.loads(raw) if raw.strip() else None | ||
| except Exception: | ||
| data = None | ||
| if not isinstance(data, dict): | ||
| return None | ||
|
|
||
| token = data.get("token") if isinstance(data.get("token"), dict) else {} | ||
| expires = parse_time(token.get("expiry") or data.get("expiry")) | ||
| return { | ||
| "accessToken": str(token.get("access_token") or data.get("access_token") or ""), | ||
| "expiresAt": expires.timestamp() if expires else 0, | ||
| "authMethod": str(data.get("auth_method") or "consumer"), | ||
| "email": email_of(data.get("id_token")), | ||
| } | ||
|
|
||
|
|
||
| def email_of(id_token): | ||
| try: | ||
| claims = str(id_token).split(".")[1] | ||
| return str(json.loads(base64.urlsafe_b64decode(claims + "=" * (-len(claims) % 4))).get("email") or "") | ||
| except Exception: | ||
| return "" | ||
|
|
||
|
|
||
| def normalize_tier_label(name, tier_id=""): | ||
| for word in ("ultra", "pro", "free"): | ||
| if word in tier_id.lower() or word in name.lower(): | ||
| return word.capitalize() | ||
| if name.lower().startswith("google ai "): | ||
| return name[10:].strip() | ||
| return name or tier_id.replace("-tier", "").capitalize() | ||
|
|
||
|
|
||
| # A paid tier names the plan; without one, the current tier does. | ||
| def extract_tier(data): | ||
| for key in ("paidTier", "currentTier"): | ||
| tier = data.get(key) | ||
| if isinstance(tier, dict): | ||
| name = str(tier.get("name") or "").strip() | ||
| tier_id = str(tier.get("id") or "").strip() | ||
| if name or tier_id: | ||
| return normalize_tier_label(name, tier_id) | ||
| return "" | ||
|
|
||
|
|
||
| def used(bucket): | ||
| return round(max(0.0, min(1.0, 1.0 - float(bucket.get("remainingFraction", 1.0)))), 4) | ||
|
|
||
|
|
||
| def reset_of(bucket): | ||
| parsed = parse_time(bucket.get("resetTime")) | ||
| return parsed.isoformat() if parsed else "" | ||
|
|
||
|
|
||
| # The Gemini group's 5-hour and weekly buckets are the plan's own limits; the | ||
| # other groups (Claude and GPT models) are scoped allowances, shown once | ||
| # they have been touched. | ||
| def extract_limits(data): | ||
| groups = [g for g in data.get("groups") or [] if isinstance(g, dict)] | ||
| if not groups: | ||
| return [] | ||
| gemini = next((g for g in groups if "gemini" in str(g.get("displayName") or "").lower()), groups[0]) | ||
|
|
||
| limits = [] | ||
| session = weekly = None | ||
| for bucket in gemini.get("buckets") or []: | ||
| if not isinstance(bucket, dict): | ||
| continue | ||
| window = str(bucket.get("window") or "").lower() | ||
| bucket_id = str(bucket.get("bucketId") or "").lower() | ||
| name = str(bucket.get("displayName") or "").lower() | ||
| if window == "5h" or "5h" in bucket_id or "five" in name: | ||
| session = bucket | ||
| elif window == "weekly" or "weekly" in bucket_id or "week" in name: | ||
| weekly = bucket | ||
| if session: | ||
| limits.append({"label": "Session (5-hour)", "title": "Session", "percent": used(session), "resetsAt": reset_of(session)}) | ||
| if weekly: | ||
| limits.append({"label": "Weekly (7-day)", "title": "Weekly", "percent": used(weekly), "resetsAt": reset_of(weekly)}) | ||
|
|
||
| for group in groups: | ||
| if group is gemini: | ||
| continue | ||
| group_name = str(group.get("displayName") or "").strip() | ||
| short = "Claude/GPT" if "claude" in group_name.lower() else (group_name or "3P") | ||
| for bucket in group.get("buckets") or []: | ||
| if isinstance(bucket, dict) and used(bucket) > 0: | ||
| title = f"{short} {'Weekly' if 'week' in str(bucket.get('window') or '').lower() else 'Session'}" | ||
| limits.append({"label": title, "title": title, "percent": used(bucket), "resetsAt": reset_of(bucket)}) | ||
| return limits | ||
|
|
||
|
|
||
| def post(endpoint, token): | ||
| base = (os.environ.get("CLOUD_CODE_URL") or "https://daily-cloudcode-pa.googleapis.com").rstrip("/") | ||
| request = urllib.request.Request( | ||
| f"{base}/v1internal:{endpoint}", data=b"{}", | ||
| headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json", "User-Agent": "antigravity"}, | ||
| ) | ||
| with urllib.request.urlopen(request, timeout=8) as response: | ||
| return json.loads(response.read().decode("utf-8", errors="replace")) | ||
|
|
||
|
|
||
| # A window kept from an earlier check is dropped once it has reset, since | ||
| # what it said no longer holds. | ||
| def open_windows(limits): | ||
| now = datetime.now(timezone.utc) | ||
| return [limit for limit in limits or [] if not parse_time(limit.get("resetsAt")) or parse_time(limit.get("resetsAt")) > now] | ||
|
|
||
|
|
||
| def collect_limits(entry, force): | ||
| result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False, "fetchedAtMs": 0} | ||
| if not entry: | ||
| result["usageStatusText"] = "Waiting for auth" | ||
| return result | ||
|
|
||
| cache = cache_root() / "agy-limits.json" | ||
| cached = read_json(cache) or {} | ||
| fetched_at = int(cached.get("fetchedAtMs") or 0) | ||
| kept = open_windows(cached.get("limits")) | ||
| result.update(fetchedAtMs=fetched_at, tierLabel=str(cached.get("tierLabel") or "")) | ||
|
|
||
| if not entry["accessToken"]: | ||
| result.update(limits=kept, usageStatusText="Waiting for auth") | ||
| return result | ||
|
|
||
| # agy refreshes its token while it runs; one left to lapse can't be used | ||
| # until agy starts again. | ||
| if entry["expiresAt"] and entry["expiresAt"] <= time.time(): | ||
| result.update(limits=kept, usageStatusText="Sign-in expired", authHelpText=EXPIRED_HELP) | ||
| return result | ||
|
|
||
| if kept and not force and time.time() - fetched_at / 1000 < PROBE_REUSE_SECONDS: | ||
| result.update(limits=kept, live=True) | ||
| return result | ||
|
|
||
| tier, limits = "", None | ||
| try: | ||
| tier = extract_tier(post("loadCodeAssist", entry["accessToken"])) | ||
| limits = extract_limits(post("retrieveUserQuotaSummary", entry["accessToken"])) or None | ||
| except urllib.error.HTTPError as error: | ||
| if error.code in (401, 403): | ||
| result.update(usageStatusText="Sign-in expired", authHelpText=EXPIRED_HELP) | ||
| except Exception: | ||
| pass | ||
|
|
||
| if tier: | ||
| result["tierLabel"] = tier | ||
| if limits: | ||
| result.update(limits=limits, live=True, fetchedAtMs=round(time.time() * 1000)) | ||
| write_json(cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": limits, "tierLabel": result["tierLabel"]}) | ||
| else: | ||
| result["limits"] = kept | ||
| if not result["usageStatusText"] and not kept: | ||
| result["usageStatusText"] = "Antigravity limits unavailable" | ||
| return result | ||
|
|
||
|
|
||
| def model_bucket(models, model): | ||
| return models.setdefault(model, {"inputTokens": 0, "outputTokens": 0, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0}) | ||
|
|
||
|
|
||
| def history_entries(root): | ||
| try: | ||
| lines = (root / "history.jsonl").read_text(encoding="utf-8", errors="ignore").splitlines() | ||
| except OSError: | ||
| return | ||
| for line in lines: | ||
| try: | ||
| entry = json.loads(line) | ||
| except ValueError: | ||
| continue | ||
| if isinstance(entry, dict): | ||
| yield entry | ||
|
|
||
|
|
||
| # Every prompt from history.jsonl, every conversation from it and from the | ||
| # conversation summaries, and the tokens each model step in a transcript | ||
| # reports. Steps that report no usage add no tokens. | ||
| def local_stats(root): | ||
| today = datetime.now().astimezone().date() | ||
| week = [(today - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)] | ||
| today = today.isoformat() | ||
| by_day = {day: 0 for day in week} | ||
| prompts = today_prompts = today_tokens = 0 | ||
| days, sessions, today_sessions = set(), set(), set() | ||
| today_by_model, models = {}, {} | ||
|
|
||
| for entry in history_entries(root): | ||
| day = local_day(entry.get("timestamp")) | ||
| if not day: | ||
| continue | ||
| prompts += 1 | ||
| days.add(day) | ||
| conversation = entry.get("conversationId") or entry.get("conversation_id") | ||
| if conversation: | ||
| sessions.add(conversation) | ||
| if day == today: | ||
| today_prompts += 1 | ||
| if conversation: | ||
| today_sessions.add(conversation) | ||
|
|
||
| summaries = root / "conversation_summaries.db" | ||
| if summaries.is_file(): | ||
| try: | ||
| conn = sqlite3.connect(summaries.resolve().as_uri() + "?mode=ro", uri=True, timeout=2) | ||
| try: | ||
| for conversation, modified in conn.execute("SELECT conversation_id, last_modified_time FROM conversation_summaries"): | ||
| if conversation: | ||
| sessions.add(conversation) | ||
| if local_day(modified) == today: | ||
| today_sessions.add(conversation) | ||
| finally: | ||
| conn.close() | ||
| except sqlite3.Error: | ||
| pass | ||
|
|
||
| for transcript in (root / "brain").glob("*/.system_generated/logs/transcript.jsonl"): | ||
| try: | ||
| lines = transcript.read_text(encoding="utf-8", errors="ignore").splitlines() | ||
| except OSError: | ||
| continue | ||
| for line in lines: | ||
| try: | ||
| step = json.loads(line) | ||
| except ValueError: | ||
| continue | ||
| usage = step.get("usage") if isinstance(step, dict) else None | ||
| if not isinstance(usage, dict): | ||
| continue | ||
| uncached = int(usage.get("prompt_token_count") or usage.get("input_tokens") or 0) | ||
| output = int(usage.get("candidates_token_count") or usage.get("output_tokens") or 0) | ||
| model = str(step.get("model") or "unknown") | ||
| bucket = model_bucket(models, model) | ||
| bucket["inputTokens"] += uncached | ||
| bucket["outputTokens"] += output | ||
| day = local_day(step.get("created_at")) | ||
| if day in by_day: | ||
| by_day[day] += uncached + output | ||
| if day == today: | ||
| today_tokens += uncached + output | ||
| today_by_model[model] = today_by_model.get(model, 0) + uncached + output | ||
|
|
||
| return { | ||
| "hasLocalStats": prompts > 0 or bool(sessions) or bool(models), | ||
| "todayPrompts": today_prompts, | ||
| "todaySessions": len(today_sessions), | ||
| "todayTotalTokens": today_tokens, | ||
| "todayTokensByModel": today_by_model, | ||
| "recentDays": [{"date": day, "messageCount": by_day[day]} for day in week], | ||
| "totalPrompts": prompts, | ||
| "totalSessions": len(sessions), | ||
| "activeDays": len(days), | ||
| "activeDates": sorted(days), | ||
| "modelUsage": models, | ||
| } | ||
|
|
||
|
|
||
| # A scan from before midnight would count yesterday's prompts as today's. | ||
| def cached_local_stats(root, max_age): | ||
| cache = cache_root() / "agy-stats.json" | ||
| cached = read_json(cache) or {} | ||
| today = datetime.now().astimezone().date().isoformat() | ||
| if cached.get("root") == str(root) and cached.get("day") == today and time.time() - float(cached.get("at") or 0) < max_age: | ||
| return cached["stats"] | ||
| stats = local_stats(root) | ||
| write_json(cache, {"root": str(root), "day": today, "at": time.time(), "stats": stats}) | ||
| return stats | ||
|
|
||
|
|
||
| def main(): | ||
| parser = argparse.ArgumentParser() | ||
| parser.add_argument("--force", action="store_true") | ||
| parser.add_argument("--limits-only", action="store_true") | ||
| args = parser.parse_args() | ||
|
|
||
| # Nobody signed in to Antigravity here: no limits and no plan, so without | ||
| # local history the panel skips the record. | ||
| entry = login() | ||
| limits = collect_limits(entry, args.force) | ||
| tier = limits["tierLabel"] | ||
| if not tier and entry and entry["authMethod"] == "enterprise": | ||
| tier = "Enterprise" | ||
| elif not tier and entry and entry["authMethod"] in ("cloud", "vertex"): | ||
| tier = "Cloud" | ||
|
|
||
| record = { | ||
| "schemaVersion": 1, | ||
| "id": AGENT_ID, | ||
| "name": AGENT_NAME, | ||
| "updatedAt": datetime.now(timezone.utc).isoformat(), | ||
| "ready": entry is not None, | ||
| "tierLabel": tier, | ||
| "usageStatusText": limits["usageStatusText"], | ||
| "authHelpText": limits["authHelpText"], | ||
| "limits": limits["limits"], | ||
| "limitsStale": not limits["live"], | ||
| "limitsFetchedAt": limits["fetchedAtMs"], | ||
| } | ||
| record.update(cached_local_stats(app_root(), LIMITS_ONLY_SCAN_REUSE_SECONDS if args.limits_only and not args.force else 0)) | ||
| print(json.dumps(record, separators=(",", ":"))) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.