ai-limits 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ai_limits/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
ai_limits/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
ai_limits/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ """ai-limits: AI subscription usage limits, offline by default.
2
+
3
+ ai-limits human-readable summary
4
+ ai-limits --version installed version
5
+ ai-limits json state JSON (docs/state-schema.md), also saved to ~/.cache/ai-limits/state.json
6
+ ai-limits accounts configured / detected accounts
7
+ ai-limits tap claude for Claude Code's statusLine: saves rate_limits from stdin, passes stdin through
8
+ ai-limits tap claude --print same, but prints a short status line instead of passing stdin through
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from datetime import datetime
19
+
20
+ from . import __version__, config
21
+ from .core import build_state, format_duration, write_state
22
+ from .model import normalise_home
23
+ from .providers import claude
24
+
25
+ DIM, RESET = "\033[2m", "\033[0m"
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ # JSON and Claude's status-line protocol are UTF-8, including redirected Windows streams.
30
+ for stream in (sys.stdout, sys.stderr):
31
+ if isinstance(stream, io.TextIOWrapper):
32
+ stream.reconfigure(encoding="utf-8")
33
+ args = sys.argv[1:] if argv is None else argv
34
+ cmd = args[0] if args else "show"
35
+
36
+ if args == ["--version"]:
37
+ print(f"ai-limits {__version__}")
38
+ return 0
39
+ if cmd in ("-h", "--help", "help"):
40
+ print(__doc__.strip())
41
+ return 0
42
+ if cmd == "tap":
43
+ return _tap(args[1:])
44
+ if cmd not in ("show", "json", "accounts") or len(args) > 1:
45
+ # before loading config or providers: a typo must never trigger collection (or network)
46
+ print(f"ai-limits: unknown command {' '.join(args)!r} (see --help)", file=sys.stderr)
47
+ return 2
48
+
49
+ try:
50
+ cfg = config.load()
51
+ except config.ConfigError as e:
52
+ print(f"ai-limits: {e}", file=sys.stderr)
53
+ return 2
54
+
55
+ if cmd == "accounts":
56
+ print(f"config: {cfg.path or '(none, auto-detected)'}")
57
+ net = cfg.network
58
+ print(f"network: {'on via ' + net.require_interface if net.active else 'off'}")
59
+ for acc in cfg.accounts:
60
+ print(f" {acc.id:22} {acc.provider:7} {acc.label!r:24} {acc.home}")
61
+ return 0
62
+
63
+ state = build_state(cfg)
64
+ write_state(state)
65
+ if cmd == "json":
66
+ json.dump(state, sys.stdout, ensure_ascii=False, indent=1, allow_nan=False)
67
+ print()
68
+ else:
69
+ _show(state, color=sys.stdout.isatty())
70
+ return 0
71
+
72
+
73
+ def _tap(args: list[str]) -> int:
74
+ if not args or args[0] != "claude":
75
+ print("usage: ai-limits tap claude [--print]", file=sys.stderr)
76
+ return 2
77
+ if isinstance(sys.stdin, io.TextIOWrapper):
78
+ sys.stdin.reconfigure(encoding="utf-8")
79
+ raw = sys.stdin.read()
80
+ claude.tap_from_stdin(normalise_home(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"), raw)
81
+ if "--print" in args:
82
+ try:
83
+ rl = json.loads(raw).get("rate_limits") or {}
84
+ except (ValueError, AttributeError):
85
+ rl = {}
86
+ parts = [f"{label} {rl[key]['used_percentage']:.0f}%" for key, label in (("five_hour", "5h"), ("seven_day", "7d"))
87
+ if isinstance(rl.get(key), dict) and rl[key].get("used_percentage") is not None]
88
+ print(" · ".join(parts) or "no limit data yet")
89
+ else:
90
+ sys.stdout.write(raw)
91
+ return 0
92
+
93
+
94
+ def _show(state: dict, color: bool) -> None:
95
+ dim, reset = (DIM, RESET) if color else ("", "")
96
+ now = int(time.time())
97
+ if not state["accounts"]:
98
+ print("No accounts found (no ~/.claude or ~/.codex). See ~/.config/ai-limits/config.toml.")
99
+ for acc in state["accounts"]:
100
+ head = acc["label"] + (f" {dim}{acc['plan']}{reset}" if acc["plan"] else "")
101
+ if acc["recommended"]:
102
+ head += " ★ most headroom"
103
+ if acc["status"] == "stale" and acc["updated_at"]:
104
+ head += f" {dim}[updated {format_duration(now - acc['updated_at'])} ago]{reset}"
105
+ elif acc["status"] != "ok":
106
+ head += f" {dim}[{acc['status']}{': ' + acc['message'] if acc['message'] else ''}]{reset}"
107
+ print(head)
108
+ for lim in acc["limits"]:
109
+ pct = f"{'~' if lim['estimated'] else ''}{lim['used_percent']:.0f}%"
110
+ bar = _bar(lim["used_percent"])
111
+ print(f" {lim['label']:16} {bar} {pct:>5} {dim}{_reset_text(lim['resets_at'], now)}{reset}")
112
+ net = state["network"]
113
+ print(f"{dim}network: {'on via ' + net['interface'] if net['enabled'] else 'off (offline mode)'}{reset}")
114
+
115
+
116
+ def _bar(pct: float, width: int = 12) -> str:
117
+ filled = round(min(max(pct, 0), 100) / 100 * width)
118
+ return "█" * filled + "░" * (width - filled)
119
+
120
+
121
+ def _reset_text(resets_at: int | None, now: int) -> str:
122
+ if not resets_at:
123
+ return ""
124
+ try:
125
+ when = datetime.fromtimestamp(resets_at)
126
+ except (ValueError, OverflowError, OSError):
127
+ return "reset time out of range"
128
+ same_day = when.date() == datetime.fromtimestamp(now).date()
129
+ return f"↻ {when.strftime('%H:%M' if same_day else '%a %H:%M')} (in {format_duration(resets_at - now)})"
ai_limits/config.py ADDED
@@ -0,0 +1,124 @@
1
+ """~/.config/ai-limits/config.toml, with auto-detection when no accounts are listed.
2
+
3
+ stale_after_hours = 6
4
+
5
+ [network] # off unless enabled AND require_interface is set
6
+ enabled = false
7
+ require_interface = "tun0"
8
+ min_interval_seconds = 300
9
+
10
+ [[account]]
11
+ provider = "codex"
12
+ label = "Codex · work"
13
+ home = "~/.codex-work"
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ import os
20
+ import tomllib
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from .model import AccountConfig, normalise_home
25
+
26
+ PROVIDERS = ("claude", "codex")
27
+ DEFAULT_HOMES = {
28
+ "claude": ("CLAUDE_CONFIG_DIR", "~/.claude", "Claude"),
29
+ "codex": ("CODEX_HOME", "~/.codex", "Codex"),
30
+ }
31
+
32
+
33
+ @dataclass
34
+ class NetworkConfig:
35
+ enabled: bool = False
36
+ require_interface: str | None = None
37
+ min_interval_seconds: int = 300
38
+
39
+ @property
40
+ def active(self) -> bool:
41
+ # without a required interface there is nothing to check against -> stay offline
42
+ return self.enabled and bool(self.require_interface)
43
+
44
+
45
+ @dataclass
46
+ class Config:
47
+ accounts: list[AccountConfig] = field(default_factory=list)
48
+ network: NetworkConfig = field(default_factory=NetworkConfig)
49
+ stale_after_hours: float = 6.0
50
+ path: Path | None = None
51
+
52
+
53
+ def config_dir() -> Path:
54
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "ai-limits"
55
+
56
+
57
+ def cache_dir() -> Path:
58
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "ai-limits"
59
+
60
+
61
+ class ConfigError(Exception):
62
+ pass
63
+
64
+
65
+ def load(path: Path | None = None) -> Config:
66
+ path = path or config_dir() / "config.toml"
67
+ raw: dict = {}
68
+ if path.exists():
69
+ try:
70
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
71
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError) as e:
72
+ raise ConfigError(f"{path}: {e}") from e
73
+
74
+ net = raw.get("network", {})
75
+ if not isinstance(net, dict):
76
+ raise ConfigError(f"{path}: [network] must be a table")
77
+ cfg = Config(
78
+ network=NetworkConfig(
79
+ # strict types: `enabled = "false"` must not switch the network on
80
+ enabled=_typed(path, "network.enabled", net.get("enabled", False), bool),
81
+ require_interface=_typed(path, "network.require_interface", net.get("require_interface"), str, None) or None,
82
+ min_interval_seconds=_typed(path, "network.min_interval_seconds", net.get("min_interval_seconds", 300), int),
83
+ ),
84
+ stale_after_hours=float(_typed(path, "stale_after_hours", raw.get("stale_after_hours", 6), (int, float))),
85
+ path=path if path.exists() else None,
86
+ )
87
+ if cfg.network.min_interval_seconds < 60:
88
+ raise ConfigError(f"{path}: network.min_interval_seconds must be at least 60")
89
+ if not (math.isfinite(cfg.stale_after_hours) and cfg.stale_after_hours > 0):
90
+ raise ConfigError(f"{path}: stale_after_hours must be a positive number")
91
+
92
+ accounts = raw.get("account", [])
93
+ if not isinstance(accounts, list) or not all(isinstance(e, dict) for e in accounts):
94
+ raise ConfigError(f"{path}: accounts must be [[account]] tables")
95
+ for i, entry in enumerate(accounts):
96
+ provider = entry.get("provider")
97
+ if provider not in PROVIDERS:
98
+ raise ConfigError(f"{path}: account #{i + 1}: provider must be one of {', '.join(PROVIDERS)}")
99
+ env, default_home, default_label = DEFAULT_HOMES[provider]
100
+ home = entry.get("home") or os.environ.get(env) or default_home
101
+ acc = AccountConfig(provider, _typed(path, "account.label", entry.get("label"), str, None) or default_label,
102
+ normalise_home(_typed(path, "account.home", home, str)))
103
+ if any(a.id == acc.id for a in cfg.accounts):
104
+ raise ConfigError(f"{path}: account #{i + 1}: {provider} {acc.home} is listed twice")
105
+ cfg.accounts.append(acc)
106
+
107
+ if not cfg.accounts:
108
+ cfg.accounts = detect_accounts()
109
+ return cfg
110
+
111
+
112
+ def _typed(path, key, value, types, *allowed):
113
+ if value in allowed or (isinstance(value, types) and not (isinstance(value, bool) and types is not bool)):
114
+ return value
115
+ raise ConfigError(f"{path}: {key} has the wrong type ({type(value).__name__})")
116
+
117
+
118
+ def detect_accounts() -> list[AccountConfig]:
119
+ found = []
120
+ for provider, (env, default_home, label) in DEFAULT_HOMES.items():
121
+ home = normalise_home(os.environ.get(env) or default_home)
122
+ if os.path.isdir(home):
123
+ found.append(AccountConfig(provider, label, home))
124
+ return found
ai_limits/core.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from . import netguard, providers
6
+ from .config import Config, cache_dir
7
+ from .model import SCHEMA, Snapshot, account_json, roll_forward
8
+ from .providers import write_json
9
+
10
+ USABLE = ("ok", "stale", "vpn_off")
11
+
12
+
13
+ def build_state(cfg: Config, now: int | None = None) -> dict:
14
+ now = now or int(time.time())
15
+ netguard.last_check = None
16
+ results = []
17
+ for acc in cfg.accounts:
18
+ try:
19
+ snap = providers.get(acc.provider).collect(acc, cfg, now)
20
+ except Exception as e: # one broken account must not hide the others
21
+ snap = Snapshot(status="error", message=f"{type(e).__name__}: {e}")
22
+ _age(snap, cfg, now)
23
+ snap.limits = [roll_forward(lim, now) for lim in snap.limits]
24
+ results.append((acc, snap))
25
+
26
+ recommended = _recommended(results)
27
+ return {
28
+ "schema": SCHEMA,
29
+ "generated_at": now,
30
+ "network": {
31
+ "enabled": cfg.network.active,
32
+ "interface": cfg.network.require_interface,
33
+ "vpn_ok": netguard.last_check, # only checks made during this run
34
+ },
35
+ "accounts": [account_json(acc, snap, acc.id in recommended) for acc, snap in results],
36
+ }
37
+
38
+
39
+ def write_state(state: dict) -> None:
40
+ write_json(cache_dir() / "state.json", state)
41
+
42
+
43
+ def _age(snap: Snapshot, cfg: Config, now: int) -> None:
44
+ if snap.status != "ok" or not snap.updated_at:
45
+ return
46
+ if now - snap.updated_at > cfg.stale_after_hours * 3600:
47
+ snap.status = "stale" # UIs show the age from updated_at themselves
48
+
49
+
50
+ def _recommended(results) -> set[str]:
51
+ """Per provider with 2+ accounts: the usable account whose busiest limit is lowest."""
52
+ by_provider: dict[str, list] = {}
53
+ for acc, snap in results:
54
+ by_provider.setdefault(acc.provider, []).append((acc, snap))
55
+ picked = set()
56
+ for accounts in by_provider.values():
57
+ if len(accounts) < 2:
58
+ continue
59
+ usable = [(max(lim.used_percent for lim in s.limits), -(s.updated_at or 0), a.id)
60
+ for a, s in accounts if s.status in USABLE and s.limits]
61
+ if usable:
62
+ picked.add(min(usable)[2])
63
+ return picked
64
+
65
+
66
+ def format_duration(seconds: int) -> str:
67
+ seconds = max(0, int(seconds))
68
+ if seconds < 3600:
69
+ return f"{seconds // 60}m"
70
+ if seconds < 86400:
71
+ return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
72
+ return f"{seconds // 86400}d{(seconds % 86400) // 3600}h"
ai_limits/model.py ADDED
@@ -0,0 +1,110 @@
1
+ """Data model shared by providers and the output (see docs/state-schema.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import os
8
+ from dataclasses import asdict, dataclass, field
9
+ from datetime import datetime, timezone
10
+
11
+ SCHEMA = 1
12
+
13
+
14
+ @dataclass
15
+ class Limit:
16
+ id: str # "5h" | "7d" | "7d:<model>" | ...
17
+ label: str
18
+ used_percent: float
19
+ resets_at: int | None = None
20
+ window_minutes: int | None = None
21
+ estimated: bool = False
22
+
23
+
24
+ @dataclass
25
+ class AccountConfig:
26
+ provider: str # "claude" | "codex"
27
+ label: str
28
+ home: str # absolute, normalised
29
+
30
+ @property
31
+ def id(self) -> str:
32
+ return f"{self.provider}-{home_key(self.home)}"
33
+
34
+
35
+ @dataclass
36
+ class Snapshot:
37
+ """What a provider found for one account."""
38
+
39
+ limits: list[Limit] = field(default_factory=list)
40
+ source: str | None = None # "tap" | "sessions" | "network"
41
+ updated_at: int | None = None
42
+ plan: str | None = None
43
+ status: str = "ok" # "ok" | "stale" | "no_data" | "vpn_off" | "error"
44
+ message: str | None = None
45
+
46
+
47
+ def home_key(home: str) -> str:
48
+ """Stable 12-char key for a config dir; the statusline tap uses the same one."""
49
+ return hashlib.sha1(home.encode()).hexdigest()[:12]
50
+
51
+
52
+ def normalise_home(path: str) -> str:
53
+ return os.path.realpath(os.path.expanduser(path))
54
+
55
+
56
+ def percent(value) -> float | None:
57
+ """A usable percentage (finite, >= 0) or None; rejects NaN/inf/strings so the JSON stays valid."""
58
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
59
+ return None
60
+ value = float(value)
61
+ return value if math.isfinite(value) and value >= 0 else None
62
+
63
+
64
+ def to_epoch(value) -> int | None:
65
+ """Epoch seconds/milliseconds or ISO-8601 -> epoch seconds; naive ISO times mean UTC."""
66
+ if value is None or value == "":
67
+ return None
68
+ if isinstance(value, bool):
69
+ return None
70
+ try:
71
+ if isinstance(value, (int, float)):
72
+ if not math.isfinite(value):
73
+ return None
74
+ return int(value / 1000) if value > 1e11 else int(value)
75
+ when = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
76
+ if when.tzinfo is None:
77
+ when = when.replace(tzinfo=timezone.utc)
78
+ return int(when.timestamp())
79
+ except (ValueError, OverflowError, OSError):
80
+ return None
81
+
82
+
83
+ def roll_forward(limit: Limit, now: int) -> Limit:
84
+ """A window that already reset is at 0 % now; move resets_at to the next window if we know its length."""
85
+ if limit.resets_at is None or limit.resets_at > now:
86
+ return limit
87
+ limit.used_percent = 0.0
88
+ limit.estimated = True
89
+ if limit.window_minutes and limit.window_minutes > 0:
90
+ step = limit.window_minutes * 60
91
+ limit.resets_at += ((now - limit.resets_at) // step + 1) * step
92
+ else:
93
+ limit.resets_at = None
94
+ return limit
95
+
96
+
97
+ def account_json(acc: AccountConfig, snap: Snapshot, recommended: bool) -> dict:
98
+ return {
99
+ "id": acc.id,
100
+ "provider": acc.provider,
101
+ "label": acc.label,
102
+ "home": acc.home,
103
+ "plan": snap.plan,
104
+ "source": snap.source,
105
+ "updated_at": snap.updated_at,
106
+ "status": snap.status,
107
+ "message": snap.message,
108
+ "recommended": recommended,
109
+ "limits": [asdict(lim) for lim in snap.limits],
110
+ }
ai_limits/netguard.py ADDED
@@ -0,0 +1,147 @@
1
+ """Fail-closed network access: a credentialed request leaves only through the VPN interface.
2
+
3
+ Order matters:
4
+ 1. the interface must exist, checked before any DNS lookup;
5
+ 2. the resolved IP must route via the interface (`ip route get` honours policy routing,
6
+ e.g. sing-box/xray TUN tables);
7
+ 3. an anonymous probe to the same host and IP must get the exact answer an allowed region gets;
8
+ 4. only then the real request, to exactly that IP.
9
+ Every socket is also bound to the interface (SO_BINDTODEVICE, unprivileged since Linux 5.7), so if
10
+ the VPN drops between the checks and the request, the kernel refuses to send it elsewhere.
11
+ Environment proxies are ignored and redirects are not followed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import http.client
17
+ import json
18
+ import os
19
+ import socket
20
+ import ssl
21
+ import subprocess
22
+ import sys
23
+ import time
24
+
25
+ PROBE_ATTEMPTS = 2
26
+ TIMEOUT = 5 # per connection; worst case (route + 2 probes + request) stays under the extension's 30 s
27
+ SUBPROCESS_ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL": "C"} # no inherited secrets
28
+
29
+ # Result of the last check in this process: None = no check ran, True/False otherwise.
30
+ last_check: bool | None = None
31
+
32
+
33
+ class VpnCheckFailed(Exception):
34
+ pass
35
+
36
+
37
+ def require_supported_platform() -> None:
38
+ """Do not substitute an unguarded transport on non-Linux platforms."""
39
+ if sys.platform != "linux" or not hasattr(socket, "SO_BINDTODEVICE"):
40
+ raise VpnCheckFailed("network mode requires Linux with SO_BINDTODEVICE; use offline mode on this platform")
41
+
42
+
43
+ def check_route(host: str, interface: str) -> str:
44
+ """Return the IPv4 address to use for `host`, or raise VpnCheckFailed."""
45
+ require_supported_platform()
46
+ if not interface or not os.path.exists(f"/sys/class/net/{interface}"):
47
+ raise VpnCheckFailed(f"interface {interface or '?'} is down")
48
+ try:
49
+ ip = socket.getaddrinfo(host, 443, socket.AF_INET, socket.SOCK_STREAM)[0][4][0]
50
+ except (OSError, IndexError) as e:
51
+ raise VpnCheckFailed(f"cannot resolve {host}: {e}") from e
52
+ try:
53
+ out = subprocess.run(["ip", "route", "get", ip], capture_output=True, text=True, timeout=5,
54
+ encoding="ascii", errors="replace", check=True, env=SUBPROCESS_ENV).stdout
55
+ except (OSError, subprocess.SubprocessError) as e:
56
+ raise VpnCheckFailed(f"cannot check route: {e}") from e
57
+ tokens = out.split()
58
+ dev = tokens[tokens.index("dev") + 1] if "dev" in tokens[:-1] else None
59
+ if dev != interface:
60
+ raise VpnCheckFailed(f"{host} would go via {dev or '?'}, not {interface}")
61
+ return ip
62
+
63
+
64
+ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
65
+ """Connects to a fixed IP through a fixed interface, verifying TLS for the real host name."""
66
+
67
+ def __init__(self, host: str, ip: str, interface: str, **kw):
68
+ super().__init__(host, **kw)
69
+ self._ip = ip
70
+ self._interface = interface
71
+
72
+ def connect(self):
73
+ require_supported_platform()
74
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
75
+ try:
76
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self._interface.encode())
77
+ sock.settimeout(self.timeout)
78
+ sock.connect((self._ip, self.port))
79
+ self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
80
+ except OSError:
81
+ sock.close()
82
+ raise
83
+
84
+
85
+ def _tls_context() -> ssl.SSLContext:
86
+ ctx = ssl.create_default_context()
87
+ ctx.keylog_filename = None # ignore an inherited SSLKEYLOGFILE: it would write session keys to disk
88
+ return ctx
89
+
90
+
91
+ def _get(host: str, ip: str, interface: str, path: str, headers: dict, timeout: float) -> tuple[int, bytes]:
92
+ conn = _PinnedHTTPSConnection(host, ip, interface, timeout=timeout, context=_tls_context())
93
+ try:
94
+ conn.request("GET", path, headers={"Accept": "application/json", **headers})
95
+ resp = conn.getresponse()
96
+ return resp.status, resp.read()
97
+ finally:
98
+ conn.close()
99
+
100
+
101
+ def probe(host: str, ip: str, interface: str, path: str, expect_status: int, expect_error: str,
102
+ timeout: float = TIMEOUT) -> None:
103
+ """Anonymous request (no token, no cookies) to the same host and IP the real request will use.
104
+
105
+ Being the same domain, it takes the same VPN routing rule. Only the exact answer an allowed
106
+ region gets for a missing token counts; a region block (403) or anything unexpected raises.
107
+ """
108
+ for attempt in range(PROBE_ATTEMPTS): # anonymous, so retrying a flaky tunnel is harmless
109
+ try:
110
+ status, body = _get(host, ip, interface, path, {}, timeout)
111
+ break
112
+ except (OSError, http.client.HTTPException) as e:
113
+ if attempt == PROBE_ATTEMPTS - 1:
114
+ raise VpnCheckFailed(f"probe to {host} failed: {e}") from e
115
+ time.sleep(1)
116
+ try:
117
+ error_type = json.loads(body)["error"]["type"]
118
+ except (ValueError, KeyError, TypeError):
119
+ error_type = None
120
+ if status != expect_status or error_type != expect_error:
121
+ raise VpnCheckFailed(f"probe to {host} got HTTP {status} {error_type or ''}".strip())
122
+
123
+
124
+ def get_json_authenticated(host: str, path: str, headers: dict, interface: str,
125
+ probe_expect: tuple[str, int, str], timeout: float = TIMEOUT) -> dict:
126
+ """GET https://host/path with credentials in `headers`, only after all checks pass.
127
+
128
+ probe_expect = (probe path, status, error type) and is mandatory.
129
+ Raises VpnCheckFailed (nothing with credentials was sent) or OSError/ValueError.
130
+ """
131
+ global last_check
132
+ if not probe_expect:
133
+ raise ValueError("an anonymous probe is required before sending credentials")
134
+ try:
135
+ ip = check_route(host, interface)
136
+ probe(host, ip, interface, *probe_expect, timeout=timeout)
137
+ except VpnCheckFailed:
138
+ last_check = False
139
+ raise
140
+ except Exception as e: # any surprise during the checks counts as a failed check
141
+ last_check = False
142
+ raise VpnCheckFailed(f"check failed: {type(e).__name__}") from e
143
+ last_check = True
144
+ status, body = _get(host, ip, interface, path, headers, timeout)
145
+ if status != 200:
146
+ raise OSError(f"HTTP {status} from {host}")
147
+ return json.loads(body)
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ def read_json(path) -> dict | None:
10
+ try:
11
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
12
+ except (OSError, ValueError):
13
+ return None
14
+ return data if isinstance(data, dict) else None
15
+
16
+
17
+ def private_dir(path) -> Path:
18
+ """Create a cache directory; restrict it to its owner on POSIX.
19
+
20
+ Windows uses ACLs, not POSIX mode bits.
21
+ """
22
+ path = Path(path)
23
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
24
+ if hasattr(os, "getuid") and path.stat().st_uid == os.getuid():
25
+ path.chmod(0o700)
26
+ return path
27
+
28
+
29
+ def write_json(path, data: dict) -> None:
30
+ """Atomic write through an exclusive temp file (0600 on POSIX, inherited ACLs on Windows)."""
31
+ path = Path(path)
32
+ fd, tmp = tempfile.mkstemp(dir=private_dir(path.parent), prefix=f".{path.name}.", suffix=".tmp")
33
+ try:
34
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
35
+ json.dump(data, f, allow_nan=False)
36
+ os.replace(tmp, path)
37
+ except BaseException:
38
+ os.unlink(tmp)
39
+ raise
40
+
41
+
42
+ def get(provider: str):
43
+ from . import claude, codex
44
+
45
+ return {"claude": claude, "codex": codex}[provider]
@@ -0,0 +1,209 @@
1
+ """Claude Code (claude.ai subscription).
2
+
3
+ Offline source ("tap"): Claude Code passes `rate_limits` to the status line command on stdin;
4
+ `ai-limits tap claude` (or a custom status line) saves it to
5
+ $XDG_CACHE_HOME/ai-limits/taps/claude-<home_key>.json. Nothing is read from the credentials file.
6
+
7
+ Network source (opt-in): the OAuth usage endpoint used by Claude Code's /usage. Undocumented and
8
+ may change. It also reports per-model weekly limits. The token is only read, never refreshed:
9
+ refreshing it here would race with Claude Code's own refresh.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import time
18
+ from dataclasses import asdict
19
+
20
+ from .. import netguard
21
+ from ..config import Config, cache_dir
22
+ from ..model import AccountConfig, Limit, Snapshot, home_key, percent, to_epoch
23
+ from . import private_dir, read_json, write_json
24
+
25
+ USAGE_HOST = "api.anthropic.com"
26
+ USAGE_PATH = "/api/oauth/usage"
27
+ # what an allowed region gets without a token; /v1/models because the usage path rate-limits anonymous calls
28
+ PROBE_EXPECT = ("/v1/models", 401, "authentication_error")
29
+ TOKEN_RE = re.compile(r"[A-Za-z0-9._~+/=-]{20,4096}")
30
+
31
+
32
+ def tap_path(acc: AccountConfig):
33
+ return cache_dir() / "taps" / f"claude-{home_key(acc.home)}.json"
34
+
35
+
36
+ def write_tap(home: str, status_input: dict, now: int) -> bool:
37
+ rate_limits = status_input.get("rate_limits")
38
+ if not rate_limits:
39
+ return False
40
+ if not isinstance(rate_limits, dict):
41
+ return False
42
+ kept = {key: {"used_percentage": w.get("used_percentage"), "resets_at": w.get("resets_at")}
43
+ for key, w in rate_limits.items() if key in ("five_hour", "seven_day") and isinstance(w, dict)}
44
+ write_json(cache_dir() / "taps" / f"claude-{home_key(home)}.json",
45
+ {"home": home, "captured_at": now, "rate_limits": kept})
46
+ return True
47
+
48
+
49
+ def collect(acc: AccountConfig, cfg: Config, now: int) -> Snapshot:
50
+ tap = _from_tap(acc)
51
+ net = _from_network(acc, cfg, now) if cfg.network.active else None
52
+
53
+ if tap is None and net is None:
54
+ return Snapshot(status="no_data", message="no data yet: add `ai-limits tap claude` to your status line")
55
+ sources = sorted((s for s in (tap, net) if s is not None and s.limits),
56
+ key=lambda s: s.updated_at or 0, reverse=True)
57
+ if not sources: # network failed and nothing offline
58
+ return net if net is not None else tap
59
+ # Merge by limit id, newest source first; an older source only adds limits the newer one lacks
60
+ # (e.g. per-model weeks). Nothing is dropped (a 99 % week still constrains the account even if old),
61
+ # but updated_at is the oldest contributing source, so old numbers make the account stale, not fresh.
62
+ merged = Snapshot(source=sources[0].source, plan=net.plan if net else None)
63
+ for src in sources:
64
+ known = {lim.id for lim in merged.limits}
65
+ extra = [lim for lim in src.limits if lim.id not in known]
66
+ if extra:
67
+ merged.limits += extra
68
+ merged.updated_at = min(filter(None, (merged.updated_at, src.updated_at)), default=None)
69
+ merged.limits.sort(key=lambda lim: ({"5h": 0, "7d": 1}.get(lim.id, 2), lim.id))
70
+ if net is not None and net.status != "ok": # keep the network problem visible next to offline data
71
+ merged.status, merged.message = net.status, net.message
72
+ return merged
73
+
74
+
75
+ def _from_tap(acc: AccountConfig) -> Snapshot | None:
76
+ data = read_json(tap_path(acc))
77
+ if not data:
78
+ return None
79
+ rl = data.get("rate_limits") or {}
80
+ limits = []
81
+ for key, lid, label, window in (("five_hour", "5h", "Session (5h)", 300), ("seven_day", "7d", "Week", 10080)):
82
+ w = rl.get(key)
83
+ used = percent(w.get("used_percentage")) if isinstance(w, dict) else None
84
+ if used is not None:
85
+ limits.append(Limit(lid, label, used, to_epoch(w.get("resets_at")), window))
86
+ return Snapshot(limits=limits, source="tap", updated_at=to_epoch(data.get("captured_at")))
87
+
88
+
89
+ def _from_network(acc: AccountConfig, cfg: Config, now: int) -> Snapshot:
90
+ """Cached network result, refreshed at most every min_interval_seconds (successful or not)."""
91
+ try:
92
+ netguard.require_supported_platform()
93
+ except netguard.VpnCheckFailed as e:
94
+ netguard.last_check = False
95
+ snap = _cached_snapshot(read_json(cache_dir() / "net" / f"{acc.id}.json") or {})
96
+ snap.status, snap.message = "vpn_off", str(e)
97
+ return snap
98
+
99
+ # Network mode is Linux-only. Offline imports and taps must also work without fcntl.
100
+ import fcntl
101
+
102
+ net_dir = private_dir(cache_dir() / "net")
103
+ cache_file = net_dir / f"{acc.id}.json"
104
+ with os.fdopen(os.open(net_dir / f"{acc.id}.lock", os.O_WRONLY | os.O_CREAT, 0o600), "w") as lock:
105
+ try: # one fetcher per account across processes (extension, status line, terminal)
106
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
107
+ except BlockingIOError:
108
+ return _cached_snapshot(read_json(cache_file) or {})
109
+ return _fetch_locked(acc, cfg, now, cache_file)
110
+
111
+
112
+ def _cached_snapshot(cache: dict) -> Snapshot:
113
+ snap = Snapshot(limits=_cached_limits(cache.get("limits")), source="network",
114
+ updated_at=to_epoch(cache.get("fetched_at")), plan=cache.get("plan"))
115
+ snap.status, snap.message = cache.get("status", "ok"), cache.get("message")
116
+ return snap
117
+
118
+
119
+ def _fetch_locked(acc: AccountConfig, cfg: Config, now: int, cache_file) -> Snapshot:
120
+ cache = read_json(cache_file) or {}
121
+ snap = _cached_snapshot(cache)
122
+ if now - (to_epoch(cache.get("attempted_at")) or 0) < cfg.network.min_interval_seconds:
123
+ return snap
124
+ # record the attempt before any network activity, so a killed run still counts against the interval
125
+ cache["attempted_at"] = now
126
+ write_json(cache_file, cache)
127
+
128
+ status, message = "ok", None
129
+ creds = read_json(f"{acc.home}/.credentials.json") or {}
130
+ oauth = creds.get("claudeAiOauth") or {}
131
+ token = oauth.get("accessToken")
132
+ expires = to_epoch(oauth.get("expiresAt"))
133
+ if not token:
134
+ status, message = "error", "not logged in (no OAuth token)"
135
+ elif not isinstance(token, str) or not TOKEN_RE.fullmatch(token):
136
+ status, message = "error", "OAuth token has an unexpected format" # never echo it
137
+ elif expires and expires <= now:
138
+ status, message = "stale", "token expired: run `claude` once to refresh it"
139
+ else:
140
+ try:
141
+ usage = netguard.get_json_authenticated(USAGE_HOST, USAGE_PATH, {
142
+ "Authorization": f"Bearer {token}",
143
+ "anthropic-beta": "oauth-2025-04-20",
144
+ }, cfg.network.require_interface, probe_expect=PROBE_EXPECT)
145
+ snap = _parse_usage(usage, now)
146
+ snap.plan = oauth.get("subscriptionType")
147
+ # cache only the parsed limits: no unknown fields, nothing that could break strict JSON
148
+ cache.update(limits=[asdict(lim) for lim in snap.limits], fetched_at=now, plan=snap.plan)
149
+ except netguard.VpnCheckFailed as e:
150
+ status, message = "vpn_off", f"network check failed, request not sent ({e})"
151
+ except Exception as e: # anything else after the token was handed over: never let its text through raw
152
+ status, message = "error", f"usage request failed: {_scrub(e, token)}"
153
+
154
+ cache.update(attempted_at=now, status=status, message=message)
155
+ write_json(cache_file, cache)
156
+ snap.status, snap.message = status, message
157
+ return snap
158
+
159
+
160
+ def _cached_limits(items) -> list[Limit]:
161
+ limits = []
162
+ for item in items if isinstance(items, list) else []:
163
+ try:
164
+ lim = Limit(**item)
165
+ except TypeError:
166
+ continue
167
+ if percent(lim.used_percent) is not None:
168
+ limits.append(lim)
169
+ return limits
170
+
171
+
172
+ def _scrub(error: Exception, token: str) -> str:
173
+ """Error text safe to store and show: no token, bounded length."""
174
+ return str(error).replace(token, "<token>")[:200] or type(error).__name__
175
+
176
+
177
+ def _parse_usage(usage: dict, fetched_at: int | None) -> Snapshot:
178
+ limits = []
179
+ for item in usage.get("limits") or []:
180
+ if not isinstance(item, dict):
181
+ continue
182
+ kind, pct = item.get("kind"), percent(item.get("percent"))
183
+ if pct is None:
184
+ continue
185
+ resets = to_epoch(item.get("resets_at"))
186
+ if kind == "session":
187
+ limits.append(Limit("5h", "Session (5h)", pct, resets, 300))
188
+ elif kind == "weekly_all":
189
+ limits.append(Limit("7d", "Week", pct, resets, 10080))
190
+ elif kind == "weekly_scoped":
191
+ scope = item.get("scope") or {}
192
+ name = (scope.get("model") or {}).get("display_name") or scope.get("surface") or "scoped"
193
+ limits.append(Limit(f"7d:{name.lower()}", f"{name} (week)", pct, resets, 10080))
194
+ if not limits: # older response shape
195
+ for key, lid, label, window in (("five_hour", "5h", "Session (5h)", 300), ("seven_day", "7d", "Week", 10080)):
196
+ w = usage.get(key)
197
+ used = percent(w.get("utilization")) if isinstance(w, dict) else None
198
+ if used is not None:
199
+ limits.append(Limit(lid, label, used, to_epoch(w.get("resets_at")), window))
200
+ return Snapshot(limits=limits, source="network", updated_at=fetched_at)
201
+
202
+
203
+ def tap_from_stdin(home: str, raw: str) -> None:
204
+ try:
205
+ data = json.loads(raw)
206
+ except ValueError:
207
+ return
208
+ if isinstance(data, dict):
209
+ write_tap(home, data, int(time.time()))
@@ -0,0 +1,122 @@
1
+ """OpenAI Codex CLI (ChatGPT subscription).
2
+
3
+ Offline source ("sessions"): Codex writes the server's rate-limit snapshot into its session logs
4
+ (<home>/sessions/YYYY/MM/DD/rollout-*.jsonl, `event_msg` / `token_count` records). We read the
5
+ tail of the most recently modified logs and take the newest snapshot per limit_id.
6
+ No network source yet.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from ..config import Config
17
+ from ..model import AccountConfig, Limit, Snapshot, percent, to_epoch
18
+
19
+ SCAN_FILES = 8 # most recently modified logs to look at
20
+ TAIL_BYTES = 512 * 1024
21
+
22
+
23
+ def collect(acc: AccountConfig, cfg: Config, now: int) -> Snapshot:
24
+ logs = _recent_logs(Path(acc.home))
25
+ if not logs:
26
+ return Snapshot(status="no_data", message="no Codex sessions yet")
27
+
28
+ latest: dict[str, tuple[float, dict]] = {} # limit_id -> (epoch with sub-seconds, rate_limits)
29
+ for path in logs:
30
+ for ts, rl in _snapshots(path):
31
+ lid = rl.get("limit_id") or "codex"
32
+ if lid not in latest or ts > latest[lid][0]:
33
+ latest[lid] = (ts, rl)
34
+ if not latest:
35
+ return Snapshot(status="no_data", message="no rate-limit data in recent Codex sessions")
36
+
37
+ # updated_at is the OLDEST snapshot used: nothing is dropped (an old 99 % still constrains the
38
+ # account), but old numbers then make the whole account stale instead of passing as fresh
39
+ snap = Snapshot(source="sessions", updated_at=int(min(ts for ts, _ in latest.values())))
40
+ for lid, (ts, rl) in sorted(latest.items(), key=lambda kv: (kv[0] != "codex", kv[0])):
41
+ snap.plan = snap.plan or rl.get("plan_type")
42
+ for key in ("primary", "secondary"):
43
+ w = rl.get(key)
44
+ if isinstance(w, dict) and percent(w.get("used_percent")) is not None:
45
+ snap.limits.append(_limit(lid, rl.get("limit_name"), w))
46
+ return snap
47
+
48
+
49
+ def _limit(limit_id: str, limit_name: str | None, w: dict) -> Limit:
50
+ minutes = w.get("window_minutes")
51
+ minutes = minutes if isinstance(minutes, int) and not isinstance(minutes, bool) and minutes > 0 else None
52
+ base, label = {300: ("5h", "Session (5h)"), 10080: ("7d", "Week")}.get(
53
+ minutes, (f"{minutes}m" if minutes else "?", _window_label(minutes)))
54
+ if limit_id != "codex":
55
+ name = limit_name or limit_id
56
+ base, label = f"{base}:{limit_id}", f"{name} ({label.lower()})"
57
+ return Limit(base, label, percent(w["used_percent"]), to_epoch(w.get("resets_at")), minutes)
58
+
59
+
60
+ def _window_label(minutes: int | None) -> str:
61
+ if not minutes:
62
+ return "Limit"
63
+ if minutes % 1440 == 0:
64
+ return f"{minutes // 1440}d"
65
+ if minutes % 60 == 0:
66
+ return f"{minutes // 60}h"
67
+ return f"{minutes}m"
68
+
69
+
70
+ def _recent_logs(home: Path) -> list[Path]:
71
+ found = []
72
+ for sub in ("sessions", "archived_sessions"):
73
+ root = home / sub
74
+ if not root.is_dir():
75
+ continue
76
+ for dirpath, _dirs, files in os.walk(root):
77
+ for name in files:
78
+ if name.startswith("rollout-") and name.endswith(".jsonl"):
79
+ p = Path(dirpath, name)
80
+ try:
81
+ found.append((p.stat().st_mtime, p))
82
+ except OSError:
83
+ pass
84
+ found.sort(reverse=True)
85
+ return [p for _, p in found[:SCAN_FILES]]
86
+
87
+
88
+ def _parse_ts(value) -> float | None:
89
+ """ISO-8601 with any precision/offset -> epoch float (string comparison gets these wrong)."""
90
+ try:
91
+ when = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
92
+ if when.tzinfo is None:
93
+ when = when.replace(tzinfo=timezone.utc)
94
+ return when.timestamp()
95
+ except (ValueError, OverflowError, OSError):
96
+ return None
97
+
98
+
99
+ def _snapshots(path: Path):
100
+ """(epoch, rate_limits) for each record in the file's tail."""
101
+ try:
102
+ with open(path, "rb") as f:
103
+ size = f.seek(0, os.SEEK_END)
104
+ f.seek(max(0, size - TAIL_BYTES))
105
+ chunk = f.read()
106
+ except OSError:
107
+ return
108
+ lines = chunk.split(b"\n")
109
+ if size > TAIL_BYTES:
110
+ lines = lines[1:] # first line is probably cut
111
+ for line in lines:
112
+ if b'"rate_limits"' not in line:
113
+ continue
114
+ try:
115
+ rec = json.loads(line)
116
+ except ValueError:
117
+ continue
118
+ payload = rec.get("payload") if isinstance(rec, dict) else None
119
+ rl = payload.get("rate_limits") if isinstance(payload, dict) else None
120
+ ts = _parse_ts(rec.get("timestamp")) if isinstance(rl, dict) else None
121
+ if ts is not None:
122
+ yield ts, rl
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-limits
3
+ Version: 0.1.0
4
+ Summary: AI subscription usage limits (Claude, Codex) for your desktop panel. Offline by default.
5
+ Author: jointsome0-lgtm
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jointsome0-lgtm/ai-limits
8
+ Project-URL: Repository, https://github.com/jointsome0-lgtm/ai-limits
9
+ Project-URL: Issues, https://github.com/jointsome0-lgtm/ai-limits/issues
10
+ Project-URL: Documentation, https://github.com/jointsome0-lgtm/ai-limits#readme
11
+ Keywords: claude,codex,usage,limits,gnome,offline,cli
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Dynamic: license-file
29
+
30
+ # ai-limits
31
+
32
+ See how much of your AI subscriptions is left — Claude and Codex, any number of accounts — right
33
+ in the GNOME top bar.
34
+
35
+ ```
36
+ ✳ 42% ◎ 19% Oct 27 15:49
37
+ ```
38
+
39
+ ```
40
+ Claude Max
41
+ Session (5h) ████░░░░░░ 42% ↻ 18:30 (in 2h10m)
42
+ Week ██░░░░░░░░ 18% ↻ Sun 19:00 (in 5d3h)
43
+ Codex · personal ★ most headroom
44
+ Week ██░░░░░░░░ 19% ↻ Thu 12:00 (in 2d21h)
45
+ Codex · work updated 8h ago
46
+ Week █████████░ 91% ↻ 18:00 (in 2h)
47
+ ```
48
+
49
+ **Offline by default.** ai-limits reads only what Claude Code and Codex already saved on your
50
+ machine. It sends no requests and does not read your tokens unless you turn on network mode, and
51
+ network mode refuses to send anything unless the route goes through the VPN interface you name.
52
+
53
+ > Unofficial. Not affiliated with Anthropic or OpenAI. The local formats and the usage endpoint it
54
+ > reads are undocumented and may change.
55
+
56
+ ## Install
57
+
58
+ ```sh
59
+ uv tool install ai-limits # or: pipx install ai-limits
60
+ ai-limits # check it sees your accounts
61
+ ```
62
+
63
+ For the optional GNOME Shell 45–49 extension, install from a checkout (it is not in the wheel):
64
+
65
+ ```sh
66
+ git clone https://github.com/jointsome0-lgtm/ai-limits.git
67
+ cd ai-limits
68
+ ./gnome-extension/install.sh
69
+ ```
70
+
71
+ ### Platforms
72
+
73
+ Python **3.11–3.14**, with no runtime dependencies. On distributions with an older system Python
74
+ (e.g. Ubuntu 22.04 ships 3.10), use `uv tool install ai-limits`: uv fetches a suitable Python itself.
75
+ **Linux** supports the offline CLI,
76
+ Claude tap, and VPN-guarded network mode (`ip` from iproute2 is required for network mode).
77
+ **macOS and Windows** support the offline CLI and Claude tap; network mode refuses to run
78
+ there, before reading credentials or looking up the host. The GNOME extension is Linux-only.
79
+
80
+ Config and cache use `~/.config/ai-limits` and `~/.cache/ai-limits` on all platforms, unless
81
+ `XDG_CONFIG_HOME` / `XDG_CACHE_HOME` override them. On Windows, `~` is the user profile directory
82
+ and cache privacy relies on Windows ACLs; on POSIX, directories/files use 0700/0600.
83
+ Files and CLI streams use UTF-8. Reset times display in the local timezone; timestamps without
84
+ an explicit timezone are interpreted as UTC.
85
+
86
+ ## Where the numbers come from
87
+
88
+ | Provider | Offline source | Network source (opt-in) |
89
+ |---|---|---|
90
+ | Codex | rate-limit snapshots in `~/.codex/sessions/**/rollout-*.jsonl`, updated whenever you use Codex | — |
91
+ | Claude | `rate_limits` Claude Code passes to its status line; saved by `ai-limits tap claude` | `api.anthropic.com/api/oauth/usage` (adds per-model weekly limits) |
92
+
93
+ When a window's reset time has passed, the limit is shown as `~0%` until fresh data arrives.
94
+
95
+ ### Claude: add the tap to your status line
96
+
97
+ In `~/.claude/settings.json`:
98
+
99
+ ```jsonc
100
+ "statusLine": { "type": "command", "command": "ai-limits tap claude --print" }
101
+ // already have a status line? pass stdin through to it:
102
+ "statusLine": { "type": "command", "command": "ai-limits tap claude | bash ~/.claude/statusline.sh" }
103
+ ```
104
+
105
+ ## Several accounts
106
+
107
+ Both CLIs keep each login in its own directory (`CLAUDE_CONFIG_DIR=…`, `CODEX_HOME=…`). List them in
108
+ `~/.config/ai-limits/config.toml`:
109
+
110
+ ```toml
111
+ [[account]]
112
+ provider = "claude"
113
+ label = "Claude Max"
114
+ home = "~/.claude"
115
+
116
+ [[account]]
117
+ provider = "codex"
118
+ label = "Codex · personal"
119
+ home = "~/.codex"
120
+
121
+ [[account]]
122
+ provider = "codex"
123
+ label = "Codex · work"
124
+ home = "~/.codex-work"
125
+ ```
126
+
127
+ Without a config file, `~/.claude` and `~/.codex` are picked up automatically. With two or more
128
+ accounts of one provider, the one with the most headroom gets a ★ and is the one shown in the panel.
129
+
130
+ ## Network mode (Linux only, behind a VPN)
131
+
132
+ ```toml
133
+ [network]
134
+ enabled = true
135
+ require_interface = "tun0" # mandatory: no interface, no network
136
+ min_interval_seconds = 300
137
+ ```
138
+
139
+ Before every request ai-limits:
140
+
141
+ 1. checks the interface exists — if not, it stops before even a DNS lookup;
142
+ 2. resolves the host and asks the kernel which interface the packet would leave by
143
+ (`ip route get`, which honours policy routing as used by sing-box / xray / WireGuard);
144
+ 3. sends an anonymous probe (no token, no cookies) to the same host and IP, e.g.
145
+ `api.anthropic.com/v1/models`. Being the same domain, it takes the same routing rule in your VPN
146
+ client, so a `direct` rule or a dead tunnel shows up here. Only the exact answer an allowed
147
+ region gets for a missing token (`401 authentication_error`) passes; a region block (403), a
148
+ timeout or anything unexpected stops here;
149
+ 4. only then sends the real request, to exactly that IP, ignoring proxy environment variables.
150
+
151
+ Every socket is also bound to the interface (`SO_BINDTODEVICE`), so if the VPN drops between the
152
+ checks and the request, the kernel refuses to send it another way. Any failure means no request
153
+ with your token, and the panel shows `⏸` next to the last known numbers. Tokens are only read,
154
+ never refreshed, never put on a command line, and never written anywhere.
155
+
156
+ **What this cannot see:** what your VPN client does *inside* the tunnel. If its rules send
157
+ `anthropic.com` to a `direct` outbound (or fall back to direct when the proxy is down), traffic
158
+ enters `tun0` and still leaves from your real IP. The probe catches a region block, but the only
159
+ real guarantee is in your VPN config: route these domains to the proxy with no direct fallback,
160
+ or block them when the proxy is down (a kill switch).
161
+
162
+ ## CLI
163
+
164
+ ```
165
+ ai-limits summary
166
+ ai-limits --version installed version
167
+ ai-limits json state for UIs (docs/state-schema.md), also written to ~/.cache/ai-limits/state.json
168
+ ai-limits accounts configured / detected accounts
169
+ ai-limits tap claude status line helper (see above)
170
+ ```
171
+
172
+ ## Development
173
+
174
+ ```sh
175
+ python3 -m unittest discover tests
176
+ python3 -m ai_limits json
177
+ ```
178
+
179
+ Releases go to PyPI through Trusted Publishing: run the **Build or publish to PyPI** workflow
180
+ (`publish.yml`) on `main` with mode `publish`; `build-only` just builds and checks the packages.
181
+ The PyPI publisher is owner `jointsome0-lgtm`, repository `ai-limits`, workflow `publish.yml`,
182
+ environment `pypi`. No API token is needed.
183
+
184
+ The GNOME extension only runs `ai-limits json` and draws the result; adding a provider means one
185
+ module in `ai_limits/providers/`.
186
+
187
+ ## License
188
+
189
+ MIT
@@ -0,0 +1,16 @@
1
+ ai_limits/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ ai_limits/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ ai_limits/cli.py,sha256=k9ajxC7qaa4aJ4t1H5ZFQvEQAEeGcg4Q3cbjczmkzYs,5083
4
+ ai_limits/config.py,sha256=5oWABCyRSyExr92wJk_0vdynMQEvqPiZaoW9TdMR9es,4581
5
+ ai_limits/core.py,sha256=RF5cNlTGvqJX_LAhSnSTYgTlIckl_NlkHwqqohHEzII,2491
6
+ ai_limits/model.py,sha256=naZzLHwWd2T-h0xNpjO8yGOiwwdj036NEuPsLR6Woww,3332
7
+ ai_limits/netguard.py,sha256=52SW9Ovi-AS6WGMoEjeNsP3GrYBRAUcQapdH_qkHdRg,6276
8
+ ai_limits/providers/__init__.py,sha256=ae-whWUowVcPIkvm1DA7n95oGbVmwlogFlAG_L5p0Pg,1246
9
+ ai_limits/providers/claude.py,sha256=Z40ohB3JGdm7jfJhS7w4mFTvkPyrpE47vxIwhuT7yKs,9509
10
+ ai_limits/providers/codex.py,sha256=b_f-xA7m62fB7zXttNJ4Oe2V7rCQ3wDHvk4czkJZFLk,4697
11
+ ai_limits-0.1.0.dist-info/licenses/LICENSE,sha256=rCi3alSyCyp3n9jRc9pKT7mPjPB71Y9puovy89THm9Q,1062
12
+ ai_limits-0.1.0.dist-info/METADATA,sha256=3lXqUHJt0JWgmJysmQHcvaDecya1rYqwLXmpQFti3ew,7740
13
+ ai_limits-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
14
+ ai_limits-0.1.0.dist-info/entry_points.txt,sha256=Kzn3clAtOQ69F0h8qJ9U6PC9GgJP9Cqu_6Ofg1xagDc,49
15
+ ai_limits-0.1.0.dist-info/top_level.txt,sha256=0furvkvRk_rb5gJHSywsdWlJ1vYEWC01jNa03TYw5Uw,10
16
+ ai_limits-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ai-limits = ai_limits.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agniv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ai_limits