ai-limits 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- ai_limits-0.1.0/MANIFEST.in +4 -0
- ai_limits-0.1.0/PKG-INFO +189 -0
- ai_limits-0.1.0/README.md +160 -0
- ai_limits-0.1.0/ai_limits/__init__.py +1 -0
- ai_limits-0.1.0/ai_limits/__main__.py +5 -0
- ai_limits-0.1.0/ai_limits/cli.py +129 -0
- ai_limits-0.1.0/ai_limits/config.py +124 -0
- ai_limits-0.1.0/ai_limits/core.py +72 -0
- ai_limits-0.1.0/ai_limits/model.py +110 -0
- ai_limits-0.1.0/ai_limits/netguard.py +147 -0
- ai_limits-0.1.0/ai_limits/providers/__init__.py +45 -0
- ai_limits-0.1.0/ai_limits/providers/claude.py +209 -0
- ai_limits-0.1.0/ai_limits/providers/codex.py +122 -0
- ai_limits-0.1.0/ai_limits.egg-info/PKG-INFO +189 -0
- ai_limits-0.1.0/ai_limits.egg-info/SOURCES.txt +31 -0
- ai_limits-0.1.0/ai_limits.egg-info/dependency_links.txt +1 -0
- ai_limits-0.1.0/ai_limits.egg-info/entry_points.txt +2 -0
- ai_limits-0.1.0/ai_limits.egg-info/top_level.txt +1 -0
- ai_limits-0.1.0/docs/state-schema.md +59 -0
- ai_limits-0.1.0/gnome-extension/README.md +84 -0
- ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/extension.js +491 -0
- ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/metadata.json +8 -0
- ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/prefs.js +85 -0
- ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/schemas/org.gnome.shell.extensions.ai-limits.gschema.xml +25 -0
- ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/stylesheet.css +27 -0
- ai_limits-0.1.0/gnome-extension/install.sh +74 -0
- ai_limits-0.1.0/pyproject.toml +43 -0
- ai_limits-0.1.0/setup.cfg +4 -0
- ai_limits-0.1.0/tests/fixtures/state-sample.json +24 -0
- ai_limits-0.1.0/tests/test_core.py +403 -0
- ai_limits-0.1.0/tests/test_install.py +70 -0
- ai_limits-0.1.0/tests/test_portability.py +173 -0
ai_limits-0.1.0/LICENSE
ADDED
|
@@ -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.
|
ai_limits-0.1.0/PKG-INFO
ADDED
|
@@ -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,160 @@
|
|
|
1
|
+
# ai-limits
|
|
2
|
+
|
|
3
|
+
See how much of your AI subscriptions is left — Claude and Codex, any number of accounts — right
|
|
4
|
+
in the GNOME top bar.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
✳ 42% ◎ 19% Oct 27 15:49
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
Claude Max
|
|
12
|
+
Session (5h) ████░░░░░░ 42% ↻ 18:30 (in 2h10m)
|
|
13
|
+
Week ██░░░░░░░░ 18% ↻ Sun 19:00 (in 5d3h)
|
|
14
|
+
Codex · personal ★ most headroom
|
|
15
|
+
Week ██░░░░░░░░ 19% ↻ Thu 12:00 (in 2d21h)
|
|
16
|
+
Codex · work updated 8h ago
|
|
17
|
+
Week █████████░ 91% ↻ 18:00 (in 2h)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Offline by default.** ai-limits reads only what Claude Code and Codex already saved on your
|
|
21
|
+
machine. It sends no requests and does not read your tokens unless you turn on network mode, and
|
|
22
|
+
network mode refuses to send anything unless the route goes through the VPN interface you name.
|
|
23
|
+
|
|
24
|
+
> Unofficial. Not affiliated with Anthropic or OpenAI. The local formats and the usage endpoint it
|
|
25
|
+
> reads are undocumented and may change.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
uv tool install ai-limits # or: pipx install ai-limits
|
|
31
|
+
ai-limits # check it sees your accounts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For the optional GNOME Shell 45–49 extension, install from a checkout (it is not in the wheel):
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
git clone https://github.com/jointsome0-lgtm/ai-limits.git
|
|
38
|
+
cd ai-limits
|
|
39
|
+
./gnome-extension/install.sh
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Platforms
|
|
43
|
+
|
|
44
|
+
Python **3.11–3.14**, with no runtime dependencies. On distributions with an older system Python
|
|
45
|
+
(e.g. Ubuntu 22.04 ships 3.10), use `uv tool install ai-limits`: uv fetches a suitable Python itself.
|
|
46
|
+
**Linux** supports the offline CLI,
|
|
47
|
+
Claude tap, and VPN-guarded network mode (`ip` from iproute2 is required for network mode).
|
|
48
|
+
**macOS and Windows** support the offline CLI and Claude tap; network mode refuses to run
|
|
49
|
+
there, before reading credentials or looking up the host. The GNOME extension is Linux-only.
|
|
50
|
+
|
|
51
|
+
Config and cache use `~/.config/ai-limits` and `~/.cache/ai-limits` on all platforms, unless
|
|
52
|
+
`XDG_CONFIG_HOME` / `XDG_CACHE_HOME` override them. On Windows, `~` is the user profile directory
|
|
53
|
+
and cache privacy relies on Windows ACLs; on POSIX, directories/files use 0700/0600.
|
|
54
|
+
Files and CLI streams use UTF-8. Reset times display in the local timezone; timestamps without
|
|
55
|
+
an explicit timezone are interpreted as UTC.
|
|
56
|
+
|
|
57
|
+
## Where the numbers come from
|
|
58
|
+
|
|
59
|
+
| Provider | Offline source | Network source (opt-in) |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Codex | rate-limit snapshots in `~/.codex/sessions/**/rollout-*.jsonl`, updated whenever you use Codex | — |
|
|
62
|
+
| 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) |
|
|
63
|
+
|
|
64
|
+
When a window's reset time has passed, the limit is shown as `~0%` until fresh data arrives.
|
|
65
|
+
|
|
66
|
+
### Claude: add the tap to your status line
|
|
67
|
+
|
|
68
|
+
In `~/.claude/settings.json`:
|
|
69
|
+
|
|
70
|
+
```jsonc
|
|
71
|
+
"statusLine": { "type": "command", "command": "ai-limits tap claude --print" }
|
|
72
|
+
// already have a status line? pass stdin through to it:
|
|
73
|
+
"statusLine": { "type": "command", "command": "ai-limits tap claude | bash ~/.claude/statusline.sh" }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Several accounts
|
|
77
|
+
|
|
78
|
+
Both CLIs keep each login in its own directory (`CLAUDE_CONFIG_DIR=…`, `CODEX_HOME=…`). List them in
|
|
79
|
+
`~/.config/ai-limits/config.toml`:
|
|
80
|
+
|
|
81
|
+
```toml
|
|
82
|
+
[[account]]
|
|
83
|
+
provider = "claude"
|
|
84
|
+
label = "Claude Max"
|
|
85
|
+
home = "~/.claude"
|
|
86
|
+
|
|
87
|
+
[[account]]
|
|
88
|
+
provider = "codex"
|
|
89
|
+
label = "Codex · personal"
|
|
90
|
+
home = "~/.codex"
|
|
91
|
+
|
|
92
|
+
[[account]]
|
|
93
|
+
provider = "codex"
|
|
94
|
+
label = "Codex · work"
|
|
95
|
+
home = "~/.codex-work"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Without a config file, `~/.claude` and `~/.codex` are picked up automatically. With two or more
|
|
99
|
+
accounts of one provider, the one with the most headroom gets a ★ and is the one shown in the panel.
|
|
100
|
+
|
|
101
|
+
## Network mode (Linux only, behind a VPN)
|
|
102
|
+
|
|
103
|
+
```toml
|
|
104
|
+
[network]
|
|
105
|
+
enabled = true
|
|
106
|
+
require_interface = "tun0" # mandatory: no interface, no network
|
|
107
|
+
min_interval_seconds = 300
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Before every request ai-limits:
|
|
111
|
+
|
|
112
|
+
1. checks the interface exists — if not, it stops before even a DNS lookup;
|
|
113
|
+
2. resolves the host and asks the kernel which interface the packet would leave by
|
|
114
|
+
(`ip route get`, which honours policy routing as used by sing-box / xray / WireGuard);
|
|
115
|
+
3. sends an anonymous probe (no token, no cookies) to the same host and IP, e.g.
|
|
116
|
+
`api.anthropic.com/v1/models`. Being the same domain, it takes the same routing rule in your VPN
|
|
117
|
+
client, so a `direct` rule or a dead tunnel shows up here. Only the exact answer an allowed
|
|
118
|
+
region gets for a missing token (`401 authentication_error`) passes; a region block (403), a
|
|
119
|
+
timeout or anything unexpected stops here;
|
|
120
|
+
4. only then sends the real request, to exactly that IP, ignoring proxy environment variables.
|
|
121
|
+
|
|
122
|
+
Every socket is also bound to the interface (`SO_BINDTODEVICE`), so if the VPN drops between the
|
|
123
|
+
checks and the request, the kernel refuses to send it another way. Any failure means no request
|
|
124
|
+
with your token, and the panel shows `⏸` next to the last known numbers. Tokens are only read,
|
|
125
|
+
never refreshed, never put on a command line, and never written anywhere.
|
|
126
|
+
|
|
127
|
+
**What this cannot see:** what your VPN client does *inside* the tunnel. If its rules send
|
|
128
|
+
`anthropic.com` to a `direct` outbound (or fall back to direct when the proxy is down), traffic
|
|
129
|
+
enters `tun0` and still leaves from your real IP. The probe catches a region block, but the only
|
|
130
|
+
real guarantee is in your VPN config: route these domains to the proxy with no direct fallback,
|
|
131
|
+
or block them when the proxy is down (a kill switch).
|
|
132
|
+
|
|
133
|
+
## CLI
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
ai-limits summary
|
|
137
|
+
ai-limits --version installed version
|
|
138
|
+
ai-limits json state for UIs (docs/state-schema.md), also written to ~/.cache/ai-limits/state.json
|
|
139
|
+
ai-limits accounts configured / detected accounts
|
|
140
|
+
ai-limits tap claude status line helper (see above)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
python3 -m unittest discover tests
|
|
147
|
+
python3 -m ai_limits json
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Releases go to PyPI through Trusted Publishing: run the **Build or publish to PyPI** workflow
|
|
151
|
+
(`publish.yml`) on `main` with mode `publish`; `build-only` just builds and checks the packages.
|
|
152
|
+
The PyPI publisher is owner `jointsome0-lgtm`, repository `ai-limits`, workflow `publish.yml`,
|
|
153
|
+
environment `pypi`. No API token is needed.
|
|
154
|
+
|
|
155
|
+
The GNOME extension only runs `ai-limits json` and draws the result; adding a provider means one
|
|
156
|
+
module in `ai_limits/providers/`.
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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)})"
|
|
@@ -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
|