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 +1 -0
- ai_limits/__main__.py +5 -0
- ai_limits/cli.py +129 -0
- ai_limits/config.py +124 -0
- ai_limits/core.py +72 -0
- ai_limits/model.py +110 -0
- ai_limits/netguard.py +147 -0
- ai_limits/providers/__init__.py +45 -0
- ai_limits/providers/claude.py +209 -0
- ai_limits/providers/codex.py +122 -0
- ai_limits-0.1.0.dist-info/METADATA +189 -0
- ai_limits-0.1.0.dist-info/RECORD +16 -0
- ai_limits-0.1.0.dist-info/WHEEL +5 -0
- ai_limits-0.1.0.dist-info/entry_points.txt +2 -0
- ai_limits-0.1.0.dist-info/licenses/LICENSE +21 -0
- ai_limits-0.1.0.dist-info/top_level.txt +1 -0
ai_limits/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ai_limits/__main__.py
ADDED
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,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
|