diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 1cf48ec17051..41711acebc19 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -22,6 +22,8 @@ from types import SimpleNamespace from typing import Any, Dict, List +from agent.credential_usage import resolve_credential_label + logger = logging.getLogger(__name__) @@ -209,6 +211,7 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]: billing_base_url=agent.base_url, billing_mode="subscription_included" if cost_result.status == "included" else None, + credential_label=resolve_credential_label(agent), model=agent.model, api_call_count=1, ) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 761c8c0f79e6..1f7dc13f9456 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -28,6 +28,7 @@ from typing import Any, Dict, List, Optional from agent.codex_responses_adapter import _summarize_user_message_for_log +from agent.credential_usage import resolve_credential_label from agent.conversation_compression import conversation_history_after_compression from agent.display import KawaiiSpinner from agent.error_classifier import FailoverReason, classify_api_error @@ -2210,6 +2211,7 @@ def _perform_api_call(next_api_kwargs): billing_base_url=agent.base_url, billing_mode="subscription_included" if cost_result.status == "included" else None, + credential_label=resolve_credential_label(agent), model=agent.model, api_call_count=1, ) diff --git a/agent/credential_usage.py b/agent/credential_usage.py new file mode 100644 index 000000000000..504d85ecb249 --- /dev/null +++ b/agent/credential_usage.py @@ -0,0 +1,46 @@ +"""Helpers for attributing model-token usage to credential-pool labels.""" +from __future__ import annotations + +from typing import Any, Optional + + +def resolve_credential_label(agent: Any) -> Optional[str]: + """Best-effort credential label for the current model call. + + The router selects a runtime token from the provider credential pool before + the agent is initialized. We intentionally store only the non-secret label, + never the token. If matching fails, return None and skip credential-level + telemetry for that call. + """ + provider = str(getattr(agent, "provider", "") or "").strip().lower() + if not provider: + return None + api_key = str(getattr(agent, "api_key", "") or "").strip() + pool = getattr(agent, "_credential_pool", None) + if pool is None: + try: + from agent.credential_pool import load_pool + + pool = load_pool(provider) + except Exception: + return None + try: + entries = pool._available_entries(clear_expired=False, refresh=False) + except Exception: + try: + entries = getattr(pool, "_entries", []) or [] + except Exception: + return None + for entry in entries: + try: + if api_key and str(getattr(entry, "runtime_api_key", "") or "").strip() == api_key: + return str(getattr(entry, "label", "") or "").strip() or None + except Exception: + continue + try: + current = pool.current() if callable(getattr(pool, "current", None)) else None + if current is not None: + return str(getattr(current, "label", "") or "").strip() or None + except Exception: + return None + return None diff --git a/gateway/run.py b/gateway/run.py index 51e33ceb4996..0d0c0e8508da 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9640,6 +9640,9 @@ async def _do_undo(): if canonical == "usage": return await self._handle_usage_command(event) + if canonical == "codex-usage": + return await self._handle_codex_usage_command(event) + if canonical == "credits": return await self._handle_credits_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 14687e07dde0..f3ac1b10d8f6 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -4054,6 +4054,43 @@ async def _handle_usage_command(self, event: MessageEvent) -> str: return "\n".join(parts) return t("gateway.usage.no_data") + async def _handle_codex_usage_command(self, event: MessageEvent) -> str: + """Handle /codex-usage β€” current Codex quota by Hermes credential.""" + raw_args = event.get_command_args().strip() if event else "" + argv = shlex.split(raw_args) if raw_args else ["--compact"] + if not argv: + argv = ["--compact"] + try: + from hermes_cli.codex_usage import collect, render_alert, render_compact, render_text + + payload = await asyncio.to_thread(collect) + primary = None + secondary = None + alert = None + verbose = False + i = 0 + while i < len(argv): + arg = argv[i] + if arg in {"--verbose", "verbose"}: + verbose = True + elif arg == "--alert-threshold" and i + 1 < len(argv): + i += 1 + alert = float(argv[i]) + elif arg == "--primary-threshold" and i + 1 < len(argv): + i += 1 + primary = float(argv[i]) + elif arg == "--secondary-threshold" and i + 1 < len(argv): + i += 1 + secondary = float(argv[i]) + i += 1 + if alert is not None or primary is not None or secondary is not None: + out = render_alert(payload, alert, None, primary_threshold=primary, secondary_threshold=secondary) + return out or "Codex usage OK." + return render_text(payload) if verbose else render_compact(payload) + except Exception as exc: + logger.warning("/codex-usage failed: %s", exc) + return f"Codex usage check failed: {exc}" + async def _handle_insights_command(self, event: MessageEvent) -> str: """Handle /insights command -- show usage insights and analytics.""" args = event.get_command_args().strip() diff --git a/hermes_cli/codex_usage.py b/hermes_cli/codex_usage.py new file mode 100644 index 000000000000..0864eb4982c5 --- /dev/null +++ b/hermes_cli/codex_usage.py @@ -0,0 +1,455 @@ +"""OpenAI Codex quota usage helpers for Hermes. + +This module powers both the `hermes codex-usage` CLI command and local watchdog +scripts. It reads Hermes' `openai-codex` credential pool and calls the Codex +quota endpoint once per credential. Runtime OAuth tokens are never printed. +""" +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from hermes_cli.config import get_hermes_home + +USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" +DEFAULT_WATERMARK = get_hermes_home() / "state" / "codex_usage_alerts.json" + + +def local_dt(epoch: Any) -> Optional[datetime]: + if isinstance(epoch, str): + try: + return datetime.fromisoformat(epoch).astimezone() + except ValueError: + return None + if not isinstance(epoch, (int, float)): + return None + return datetime.fromtimestamp(epoch).astimezone() + + +def human_delta(target: Optional[datetime], now: Optional[datetime] = None) -> str: + if target is None: + return "unknown" + now = now or datetime.now().astimezone() + seconds = int((target - now).total_seconds()) + if seconds <= 0: + return "already reset" + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, _ = divmod(rem, 60) + parts: list[str] = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes or not parts: + parts.append(f"{minutes}m") + return " ".join(parts) + + +def risk(used_percent: Any) -> Dict[str, str]: + try: + value = float(used_percent) + except (TypeError, ValueError): + return {"level": "unknown", "icon": "βšͺ", "label": "unknown"} + if value >= 95: + return {"level": "critical", "icon": "πŸ”΄", "label": "거의 μ†Œμ§„"} + if value >= 80: + return {"level": "warning", "icon": "🟠", "label": "주의"} + return {"level": "ok", "icon": "🟒", "label": "정상"} + + +def summarize_window(window: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not isinstance(window, dict): + return None + reset_dt = local_dt(window.get("reset_at")) + used = window.get("used_percent") + return { + "used_percent": used, + "limit_window_seconds": window.get("limit_window_seconds"), + "reset_at": reset_dt.isoformat(timespec="seconds") if reset_dt else window.get("reset_at"), + "remaining": human_delta(reset_dt), + "risk": risk(used), + } + + +# Backward-compatible alias used by the first local helper script. +window_summary = summarize_window + + +def fetch_usage(access_token: str, account_id: Optional[str] = None, timeout: int = 30) -> Dict[str, Any]: + headers = { + "Authorization": f"Bearer {access_token}", + "User-Agent": "Hermes Codex usage check", + "Accept": "application/json", + } + if account_id: + headers["ChatGPT-Account-Id"] = account_id + req = urllib.request.Request(USAGE_URL, headers=headers, method="GET") + with urllib.request.urlopen(req, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def collect() -> Dict[str, Any]: + from agent.credential_pool import load_pool + + pool = load_pool("openai-codex") + # clear_expired=True + refresh=True lets Hermes refresh OAuth tokens if needed. + entries = pool._available_entries(clear_expired=True, refresh=True) # intentional internal API + now = datetime.now().astimezone() + rows: list[dict[str, Any]] = [] + for entry in entries: + row: Dict[str, Any] = { + "label": entry.label, + "priority": entry.priority, + "source": entry.source, + "last_status": entry.last_status or "ok", + } + account_id = ( + entry.extra.get("account_id") + or entry.extra.get("chatgpt_account_id") + or entry.extra.get("accountId") + ) + try: + data = fetch_usage(entry.runtime_api_key, account_id=account_id) + rate_limit = data.get("rate_limit") or {} + credits = data.get("credits") or {} + row.update( + { + "ok": True, + "plan_type": data.get("plan_type"), + "primary_window": summarize_window(rate_limit.get("primary_window")), + "secondary_window": summarize_window(rate_limit.get("secondary_window")), + "credits": { + key: credits.get(key) + for key in ("has_credits", "unlimited", "balance") + if key in credits + }, + } + ) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace")[:500] + row.update({"ok": False, "http": exc.code, "error": body}) + except Exception as exc: # noqa: BLE001 - CLI report should survive per-account failures + row.update({"ok": False, "error": f"{type(exc).__name__}: {exc}"}) + rows.append(row) + payload = {"checked_at": now.isoformat(timespec="seconds"), "provider": "openai-codex", "accounts": rows} + payload["recommendation"] = compute_recommendation(rows) + return payload + + +def _percent(row: Dict[str, Any], window_name: str) -> float: + try: + value = (row.get(window_name) or {}).get("used_percent") + if value is None: + return 999.0 + return float(value) + except (TypeError, ValueError): + return 999.0 + + +def compute_recommendation(accounts: List[Dict[str, Any]]) -> Optional[Dict[str, str]]: + ok_accounts = [row for row in accounts if row.get("ok")] + if not ok_accounts: + return None + best = min(ok_accounts, key=lambda row: (_percent(row, "secondary_window"), _percent(row, "primary_window"))) + sec = best.get("secondary_window") or {} + pri = best.get("primary_window") or {} + reason = f"7d {sec.get('used_percent', '?')}%, 5h {pri.get('used_percent', '?')}%" + return {"label": str(best.get("label")), "reason": reason} + + +# Backward-compatible alias. +recommend = compute_recommendation + + +def iter_windows(row: Dict[str, Any]) -> Iterable[Tuple[str, str, Dict[str, Any]]]: + yield "5h", "primary_window", row.get("primary_window") or {} + yield "7d", "secondary_window", row.get("secondary_window") or {} + + +def render_text(payload: Dict[str, Any]) -> str: + lines = [f"Codex usage ({payload['checked_at']})"] + accounts = payload.get("accounts") or [] + if not accounts: + lines.append("No available openai-codex credentials found.") + return "\n".join(lines) + recommendation = payload.get("recommendation") or {} + if recommendation: + lines.append(f"μΆ”μ²œ: {recommendation.get('label')} ({recommendation.get('reason')})") + for row in accounts: + lines.append("") + lines.append(f"[{row.get('label')}] plan={row.get('plan_type', 'unknown')} status={row.get('last_status', 'unknown')}") + if not row.get("ok"): + lines.append(f" ERROR: {row.get('http', '')} {row.get('error', '')}".rstrip()) + continue + for display, _key, window in iter_windows(row): + r = window.get("risk") or risk(window.get("used_percent")) + lines.append( + f" {display}: {r.get('icon')} " + f"{window.get('used_percent', '?')}% used ({r.get('label')}), " + f"reset {window.get('reset_at', '?')} ({window.get('remaining', '?')})" + ) + credits = row.get("credits") or {} + if credits: + lines.append( + " credits: " + f"has={credits.get('has_credits')}, " + f"unlimited={credits.get('unlimited')}, " + f"balance={credits.get('balance')}" + ) + return "\n".join(lines) + + +def short_reset(reset_at: Any) -> str: + if not isinstance(reset_at, str): + return "?" + try: + dt = datetime.fromisoformat(reset_at) + return dt.strftime("%m/%d %H:%M") + except ValueError: + return reset_at + + +def short_checked_at(checked_at: Any) -> str: + if not isinstance(checked_at, str): + return "?" + try: + return datetime.fromisoformat(checked_at).strftime("%m/%d %H:%M") + except ValueError: + return checked_at + + +def usage_bar(used_percent: Any, width: int = 10) -> str: + """Return a compact text progress bar for Telegram/CLI scanning.""" + try: + value = max(0.0, min(100.0, float(used_percent))) + except (TypeError, ValueError): + return "?" * max(1, width) + filled = int(value / 100 * width) + if value > 0 and filled == 0: + filled = 1 + if value >= 95: + filled = width + return "β–ˆ" * filled + "β–‘" * (width - filled) + + +def _fmt_percent(value: Any) -> str: + try: + f = float(value) + except (TypeError, ValueError): + return " ?%" + if f.is_integer(): + return f"{int(f):>2}%" + return f"{f:>4.1f}%" + + +def _worst_risk(accounts: list[dict[str, Any]]) -> dict[str, str]: + order = {"critical": 3, "warning": 2, "ok": 1, "unknown": 0} + worst = {"level": "unknown", "icon": "βšͺ", "label": "unknown"} + for row in accounts: + if not row.get("ok"): + return {"level": "error", "icon": "❌", "label": "쑰회 μ‹€νŒ¨"} + for _display, _key, window in iter_windows(row): + r = window.get("risk") or risk(window.get("used_percent")) + if order.get(r.get("level", "unknown"), 0) > order.get(worst.get("level", "unknown"), 0): + worst = r + return worst + + +def render_compact(payload: Dict[str, Any]) -> str: + accounts = payload.get("accounts") or [] + worst = _worst_risk(accounts) + lines = [f"🧭 Codex μ‚¬μš©λŸ‰ Β· {short_checked_at(payload.get('checked_at'))} Β· {worst.get('icon')} {worst.get('label')}"] + recommendation = payload.get("recommendation") or {} + if recommendation: + lines.append(f"βœ… μΆ”μ²œ {recommendation.get('label')} β€” {recommendation.get('reason')}") + if not accounts: + lines.append("계정 μ—†μŒ") + return "\n".join(lines) + for row in accounts: + label = row.get("label") + plan = row.get("plan_type") or "unknown" + if not row.get("ok"): + lines.append(f"\n❌ {label} Β· {plan}") + lines.append(f"β”” ERROR {row.get('http', '')} {row.get('error', '')}".rstrip()) + continue + lines.append(f"\nβ€’ {label} Β· {plan}") + for display, _key, window in iter_windows(row): + r = window.get("risk") or risk(window.get("used_percent")) + used = window.get("used_percent") + lines.append( + f" {display:>2} {_fmt_percent(used)} {r.get('icon')} " + f"[{usage_bar(used)}] reset {short_reset(window.get('reset_at'))} Β· {window.get('remaining', '?')}" + ) + return "\n".join(lines) + + +def load_seen(path: Path) -> set[str]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, list): + return {str(item) for item in data} + except FileNotFoundError: + return set() + except Exception: + return set() + return set() + + +def save_seen(path: Path, seen: set[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(sorted(seen), ensure_ascii=False, indent=2), encoding="utf-8") + + +def apply_alert_policy( + payload: Dict[str, Any], + *, + threshold: Optional[float] = None, + primary_threshold: Optional[float] = None, + secondary_threshold: Optional[float] = None, +) -> List[Dict[str, Any]]: + """Return alert events from a collected usage payload. + + `threshold` is the legacy single threshold. `primary_threshold` controls the + 5h window and `secondary_threshold` controls the 7d window. Missing specific + thresholds fall back to `threshold`, then 90%. + """ + fallback = 90.0 if threshold is None else float(threshold) + primary = float(primary_threshold) if primary_threshold is not None else fallback + secondary = float(secondary_threshold) if secondary_threshold is not None else fallback + thresholds = {"primary_window": primary, "secondary_window": secondary} + + events: list[dict[str, Any]] = [] + for row in payload.get("accounts") or []: + if not row.get("ok"): + events.append({"key": f"error:{row.get('label')}:{row.get('http')}:{row.get('error')}", "row": row, "error": True}) + continue + for display, key, window in iter_windows(row): + try: + raw_used = window.get("used_percent") + if raw_used is None: + continue + used = float(raw_used) + except (TypeError, ValueError): + continue + if used >= thresholds[key]: + reset_key = str(window.get("reset_at"))[:16] + events.append( + { + "key": f"quota:{row.get('label')}:{key}:{reset_key}", + "label": row.get("label"), + "window": display, + "window_key": key, + "threshold": thresholds[key], + "used": used if used % 1 else int(used), + "reset_at": window.get("reset_at"), + "remaining": window.get("remaining"), + "risk": window.get("risk") or risk(used), + } + ) + return events + + +def alert_events(payload: Dict[str, Any], threshold: float) -> List[Dict[str, Any]]: + return apply_alert_policy(payload, threshold=threshold) + + +def render_alert( + payload: Dict[str, Any], + threshold: Optional[float] = None, + watermark: Optional[Path] = None, + *, + primary_threshold: Optional[float] = None, + secondary_threshold: Optional[float] = None, +) -> str: + events = apply_alert_policy( + payload, + threshold=threshold, + primary_threshold=primary_threshold, + secondary_threshold=secondary_threshold, + ) + if watermark: + seen = load_seen(watermark) + fresh = [event for event in events if event["key"] not in seen] + if fresh: + seen.update(event["key"] for event in fresh) + save_seen(watermark, seen) + events = fresh + if not events: + return "" + lines = [f"⚠️ Codex usage alert ({payload['checked_at']})"] + for event in events: + if event.get("error"): + row = event["row"] + lines.append(f"{row.get('label')}: ERROR {row.get('http', '')} {row.get('error', '')}".rstrip()) + continue + r = event.get("risk") or {} + lines.append( + f"{r.get('icon', '⚠️')} {event.get('label')} {event.get('window')} " + f"{event.get('used'):g}% >= {event.get('threshold'):g}% β€” " + f"reset {event.get('reset_at')} ({event.get('remaining')})" + ) + recommendation = payload.get("recommendation") or {} + if recommendation: + lines.append(f"μΆ”μ²œ: {recommendation.get('label')} β€” {recommendation.get('reason')}") + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Show Hermes OpenAI Codex quota usage by credential") + parser.add_argument("--json", action="store_true", help="print machine-readable JSON") + parser.add_argument("--compact", action="store_true", help="print Telegram-friendly compact output (default)") + parser.add_argument("--verbose", action="store_true", help="print detailed per-account text output") + parser.add_argument("--alert-threshold", type=float, help="legacy: print only accounts/windows at or above this percent") + parser.add_argument("--primary-threshold", type=float, help="5h window alert threshold percent") + parser.add_argument("--secondary-threshold", type=float, help="7d window alert threshold percent") + parser.add_argument("--quiet-ok", action="store_true", help="with alert thresholds, print nothing when below threshold") + parser.add_argument( + "--watermark", + type=Path, + nargs="?", + const=DEFAULT_WATERMARK, + help="with alert thresholds, suppress duplicate alerts for the same reset window", + ) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + payload = collect() + alert_mode = any( + value is not None + for value in (args.alert_threshold, args.primary_threshold, args.secondary_threshold) + ) + if args.json: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + elif alert_mode: + output = render_alert( + payload, + args.alert_threshold, + args.watermark, + primary_threshold=args.primary_threshold, + secondary_threshold=args.secondary_threshold, + ) + if output: + print(output) + elif not args.quiet_ok: + primary = args.primary_threshold if args.primary_threshold is not None else args.alert_threshold or 90 + secondary = args.secondary_threshold if args.secondary_threshold is not None else args.alert_threshold or 90 + print(f"Codex usage OK: 5h below {primary:g}%, 7d below {secondary:g}%") + elif args.verbose: + print(render_text(payload)) + else: + print(render_compact(payload)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 3e2d03dc3580..a462a86c6bc4 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -229,6 +229,8 @@ class CommandDef: CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session", gateway_only=True), CommandDef("usage", "Show token usage and rate limits for the current session", "Info"), + CommandDef("codex-usage", "Show current OpenAI Codex quota usage by credential", "Info", + aliases=("codex_usage",), args_hint="[--compact|--verbose]"), CommandDef("credits", "Show Nous credit balance and top up", "Info"), CommandDef("billing", "Manage Nous terminal billing β€” buy credits, auto-reload, limits", "Info", cli_only=True), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index caca5e6a8a37..01f83f3ea197 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12639,8 +12639,34 @@ def cmd_insights(args): db = SessionDB() engine = InsightsEngine(db) - report = engine.generate(days=args.days, source=args.source) - print(engine.format_terminal(report)) + if getattr(args, "by_credential", False): + provider = getattr(args, "provider", None) + rows = db.get_credential_usage(days=args.days, provider=provider) + if rows: + title_provider = f" for {provider}" if provider else "" + print(f"Credential usage{title_provider} (last {args.days}d)") + for row in rows: + label = row.get("credential_label") or "unknown" + model = row.get("model") or "unknown" + total = row.get("total_tokens") or 0 + in_tok = row.get("input_tokens") or 0 + out_tok = row.get("output_tokens") or 0 + calls = row.get("api_calls") or 0 + print( + f" {label} Β· {model}: {total:,} tokens " + f"({in_tok:,} in, {out_tok:,} out), {calls:,} calls" + ) + else: + suffix = f" for {provider}" if provider else "" + print( + f"No credential-level usage rows{suffix} in the last {args.days}d. " + "New rows are recorded only after this Hermes version handles model turns." + ) + else: + report = engine.generate(days=args.days, source=args.source) + if getattr(args, "provider", None): + report["provider_filter_note"] = args.provider + print(engine.format_terminal(report)) db.close() except Exception as e: print(f"Error generating insights: {e}") @@ -13936,6 +13962,37 @@ def cmd_sessions(args): # ========================================================================= build_insights_parser(subparsers, cmd_insights=cmd_insights) + codex_usage_parser = subparsers.add_parser( + "codex-usage", + help="Show OpenAI Codex quota usage for Hermes OAuth credentials", + description="Show current OpenAI Codex quota, reset windows, alerts, and recommended credential.", + ) + codex_usage_parser.add_argument("--json", action="store_true", help="print machine-readable JSON") + codex_usage_parser.add_argument("--compact", action="store_true", help="print Telegram-friendly compact output (default)") + codex_usage_parser.add_argument("--verbose", action="store_true", help="print detailed per-account text output") + codex_usage_parser.add_argument("--alert-threshold", type=float, help="legacy: alert when either window is at/above this percent") + codex_usage_parser.add_argument("--primary-threshold", type=float, help="5h window alert threshold percent") + codex_usage_parser.add_argument("--secondary-threshold", type=float, help="7d window alert threshold percent") + codex_usage_parser.add_argument("--quiet-ok", action="store_true", help="with alert thresholds, print nothing when below threshold") + codex_usage_parser.add_argument("--watermark", nargs="?", const="__DEFAULT__", help="suppress duplicate alerts for the same reset window") + def _cmd_codex_usage(_args): + from hermes_cli.codex_usage import main as _codex_usage_main + argv = [] + for _flag in ("json", "compact", "verbose", "quiet_ok"): + if getattr(_args, _flag, False): + argv.append("--" + _flag.replace("_", "-")) + for _flag in ("alert_threshold", "primary_threshold", "secondary_threshold"): + _val = getattr(_args, _flag, None) + if _val is not None: + argv.extend(["--" + _flag.replace("_", "-"), str(_val)]) + _wm = getattr(_args, "watermark", None) + if _wm is not None: + argv.append("--watermark") + if _wm != "__DEFAULT__": + argv.append(str(_wm)) + return _codex_usage_main(argv) + codex_usage_parser.set_defaults(func=_cmd_codex_usage) + # ========================================================================= # claw command (parser built in hermes_cli/subcommands/claw.py) # ========================================================================= diff --git a/hermes_cli/subcommands/insights.py b/hermes_cli/subcommands/insights.py index 42746e8030b0..b577d884946c 100644 --- a/hermes_cli/subcommands/insights.py +++ b/hermes_cli/subcommands/insights.py @@ -22,4 +22,12 @@ def build_insights_parser(subparsers, *, cmd_insights: Callable) -> None: insights_parser.add_argument( "--source", help="Filter by platform (cli, telegram, discord, etc.)" ) + insights_parser.add_argument( + "--provider", help="Filter usage by billing provider (e.g. openai-codex)" + ) + insights_parser.add_argument( + "--by-credential", + action="store_true", + help="Show per-credential token usage when credential telemetry is available", + ) insights_parser.set_defaults(func=cmd_insights) diff --git a/hermes_state.py b/hermes_state.py index a2895b09c7a2..720c06c330eb 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -767,6 +767,21 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A compacted INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS credential_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + timestamp REAL NOT NULL, + provider TEXT, + credential_label TEXT, + model TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + api_call_count INTEGER DEFAULT 0 +); + CREATE TABLE IF NOT EXISTS state_meta ( key TEXT PRIMARY KEY, value TEXT @@ -808,6 +823,8 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A ON sessions(source, user_id, chat_id, chat_type, thread_id, started_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_handoff_state ON sessions(handoff_state, started_at); +CREATE INDEX IF NOT EXISTS idx_credential_usage_time_provider + ON credential_usage(timestamp DESC, provider, credential_label); """ FTS_SQL = """ @@ -2414,6 +2431,7 @@ def update_token_counts( billing_provider: Optional[str] = None, billing_base_url: Optional[str] = None, billing_mode: Optional[str] = None, + credential_label: Optional[str] = None, api_call_count: int = 0, absolute: bool = False, ) -> None: @@ -2494,8 +2512,83 @@ def update_token_counts( ) def _do(conn): conn.execute(sql, params) + if credential_label: + conn.execute( + """INSERT INTO credential_usage ( + session_id, timestamp, provider, credential_label, model, + input_tokens, output_tokens, cache_read_tokens, + cache_write_tokens, reasoning_tokens, api_call_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + session_id, + time.time(), + billing_provider, + credential_label, + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + reasoning_tokens, + api_call_count, + ), + ) self._execute_write(_do) + def get_credential_usage( + self, + days: int = 30, + provider: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Aggregate per-credential token usage rows for recent model calls.""" + cutoff = time.time() - (max(0, int(days)) * 86400) + where = ["timestamp >= ?"] + params: list[Any] = [cutoff] + if provider: + where.append("provider = ?") + params.append(provider) + query = f""" + SELECT + COALESCE(provider, 'unknown') AS provider, + COALESCE(credential_label, 'unknown') AS credential_label, + COALESCE(model, 'unknown') AS model, + COALESCE(SUM(api_call_count), 0) AS api_calls, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens + FROM credential_usage + WHERE {' AND '.join(where)} + GROUP BY provider, credential_label, model + ORDER BY (COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) + + COALESCE(SUM(cache_read_tokens), 0) + COALESCE(SUM(cache_write_tokens), 0) + + COALESCE(SUM(reasoning_tokens), 0)) DESC, + credential_label ASC, + model ASC + """ + with self._lock: + cursor = self._conn.execute(query, tuple(params)) + rows = [dict(row) for row in cursor.fetchall()] + for row in rows: + row["api_calls"] = int(row.get("api_calls") or 0) + for key in ( + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_write_tokens", + "reasoning_tokens", + ): + row[key] = int(row.get(key) or 0) + row["total_tokens"] = ( + row["input_tokens"] + + row["output_tokens"] + + row["cache_read_tokens"] + + row["cache_write_tokens"] + + row["reasoning_tokens"] + ) + return rows + def ensure_session( self, session_id: str, diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 239b26de1712..19f671557851 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -154,6 +154,10 @@ async def _shutdown_abandoned_app(app) -> None: try: from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup + try: + from telegram import CopyTextButton + except ImportError: + CopyTextButton = None try: from telegram import LinkPreviewOptions except ImportError: @@ -176,6 +180,7 @@ async def _shutdown_abandoned_app(app) -> None: Message = Any InlineKeyboardButton = Any InlineKeyboardMarkup = Any + CopyTextButton = None LinkPreviewOptions = None Application = Any CommandHandler = Any @@ -251,7 +256,7 @@ def check_telegram_requirements() -> bool: so the adapter's class-level type aliases get rebound. """ global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton - global InlineKeyboardMarkup, LinkPreviewOptions, Application + global InlineKeyboardMarkup, CopyTextButton, LinkPreviewOptions, Application global CommandHandler, CallbackQueryHandler, TelegramMessageHandler global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest if TELEGRAM_AVAILABLE: @@ -264,6 +269,10 @@ def check_telegram_requirements() -> bool: try: from telegram import Update as _Update, Bot as _Bot, Message as _Message from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM + try: + from telegram import CopyTextButton as _CTB + except ImportError: + _CTB = None try: from telegram import LinkPreviewOptions as _LPO except ImportError: @@ -283,6 +292,7 @@ def check_telegram_requirements() -> bool: Message = _Message InlineKeyboardButton = _IKB InlineKeyboardMarkup = _IKM + CopyTextButton = _CTB LinkPreviewOptions = _LPO Application = _App CommandHandler = _CH @@ -347,6 +357,48 @@ def _separate_chunk_indicator_from_fence(text: str) -> str: return _CHUNK_INDICATOR_ON_FENCE_RE.sub(r'```\n\g', text) +# --------------------------------------------------------------------------- +# Telegram copy-to-clipboard buttons +# --------------------------------------------------------------------------- + +_COPY_BUTTON_LINE_RE = re.compile( + r'^\s*COPY_BUTTON:\s*(?P