unlimited 0.0.9__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.
- unlimited-0.0.9/.githooks/check-identity.sh +51 -0
- unlimited-0.0.9/.githooks/check-identity.test.sh +26 -0
- unlimited-0.0.9/.github/workflows/identity.yml +31 -0
- unlimited-0.0.9/.github/workflows/publish.yml +17 -0
- unlimited-0.0.9/.github/workflows/test.yml +14 -0
- unlimited-0.0.9/.gitignore +2 -0
- unlimited-0.0.9/LICENSE +21 -0
- unlimited-0.0.9/PKG-INFO +122 -0
- unlimited-0.0.9/README.md +113 -0
- unlimited-0.0.9/docs/preview.py +57 -0
- unlimited-0.0.9/docs/status.svg +108 -0
- unlimited-0.0.9/pyproject.toml +15 -0
- unlimited-0.0.9/src/unlimited/__init__.py +24 -0
- unlimited-0.0.9/src/unlimited/adapters/__init__.py +6 -0
- unlimited-0.0.9/src/unlimited/adapters/anthropic.py +237 -0
- unlimited-0.0.9/src/unlimited/adapters/openai.py +176 -0
- unlimited-0.0.9/src/unlimited/adapters/opencode.py +84 -0
- unlimited-0.0.9/src/unlimited/adapters/xai.py +75 -0
- unlimited-0.0.9/src/unlimited/adapters/zai.py +89 -0
- unlimited-0.0.9/src/unlimited/cache.py +108 -0
- unlimited-0.0.9/src/unlimited/cli.py +53 -0
- unlimited-0.0.9/src/unlimited/credential.py +10 -0
- unlimited-0.0.9/src/unlimited/projection.py +245 -0
- unlimited-0.0.9/src/unlimited/schema.py +66 -0
- unlimited-0.0.9/src/unlimited/show.py +110 -0
- unlimited-0.0.9/src/unlimited/transport.py +60 -0
- unlimited-0.0.9/tests/test_contract.py +254 -0
- unlimited-0.0.9/tests/test_projection.py +197 -0
- unlimited-0.0.9/tests/test_show.py +41 -0
- unlimited-0.0.9/tests/test_sources.py +304 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Every commit here is the owner's or a bot's, under a GitHub noreply address. Signing in as
|
|
3
|
+
# another of the owner's accounts is the mistake this catches: its address is noreply too.
|
|
4
|
+
#
|
|
5
|
+
# check-identity.sh <range> every commit in the range, and its Co-authored-by trailers
|
|
6
|
+
# check-identity.sh the identity the next commit would take
|
|
7
|
+
#
|
|
8
|
+
# GitHub's own committer is allowed for merges it performs; an AI model (Claude, Codex) may
|
|
9
|
+
# be credited as co-author.
|
|
10
|
+
set -eu
|
|
11
|
+
|
|
12
|
+
OWNER='5214595\+ren-diao@users\.noreply\.github\.com'
|
|
13
|
+
BOT='[0-9]+\+[a-z0-9-]+\[bot\]@users\.noreply\.github\.com'
|
|
14
|
+
ALLOWED_AUTHOR="^($OWNER|$BOT)$"
|
|
15
|
+
ALLOWED_COMMITTER="^($OWNER|$BOT|noreply@github\.com)$"
|
|
16
|
+
ALLOWED_COAUTHOR="^($OWNER|$BOT|[^@]+@anthropic\.com|[^@]+@openai\.com)$"
|
|
17
|
+
|
|
18
|
+
fail=0
|
|
19
|
+
report() { # role, email, where
|
|
20
|
+
printf ' %s email %s is not permitted (%s)\n' "$1" "$2" "$3" >&2
|
|
21
|
+
fail=1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if [ $# -eq 0 ]; then
|
|
25
|
+
a=$(git var GIT_AUTHOR_IDENT | sed -n 's/.*<\(.*\)>.*/\1/p')
|
|
26
|
+
c=$(git var GIT_COMMITTER_IDENT | sed -n 's/.*<\(.*\)>.*/\1/p')
|
|
27
|
+
printf '%s\n' "$a" | grep -Eq "$ALLOWED_AUTHOR" || report author "$a" "this commit"
|
|
28
|
+
printf '%s\n' "$c" | grep -Eq "$ALLOWED_COMMITTER" || report committer "$c" "this commit"
|
|
29
|
+
else
|
|
30
|
+
commits=$(git log --format='%h %ae %ce' "$1") # a bad range must exit, not read nothing
|
|
31
|
+
while read -r sha a c; do
|
|
32
|
+
[ -z "$sha" ] && continue
|
|
33
|
+
printf '%s\n' "$a" | grep -Eq "$ALLOWED_AUTHOR" || report author "$a" "$sha"
|
|
34
|
+
printf '%s\n' "$c" | grep -Eq "$ALLOWED_COMMITTER" || report committer "$c" "$sha"
|
|
35
|
+
for co in $(git log -1 --format='%(trailers:key=Co-authored-by,valueonly)' "$sha" | sed -n 's/.*<\(.*\)>.*/\1/p'); do
|
|
36
|
+
printf '%s\n' "$co" | grep -Eq "$ALLOWED_COAUTHOR" || report co-author "$co" "$sha"
|
|
37
|
+
done
|
|
38
|
+
done <<EOF
|
|
39
|
+
$commits
|
|
40
|
+
EOF
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
if [ "$fail" -ne 0 ]; then
|
|
44
|
+
cat >&2 <<'WHY'
|
|
45
|
+
|
|
46
|
+
Set this repository's own identity and commit again:
|
|
47
|
+
git config user.email '5214595+ren-diao@users.noreply.github.com'
|
|
48
|
+
git commit --amend --reset-author # if the commit is already made
|
|
49
|
+
WHY
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# check-identity.sh against commits made in a scratch repository.
|
|
3
|
+
set -eu
|
|
4
|
+
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
5
|
+
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
|
6
|
+
cd "$T" && git init -q && git config user.useConfigOnly true && git config user.name t
|
|
7
|
+
fail=0
|
|
8
|
+
commit() { # email, message
|
|
9
|
+
GIT_COMMITTER_EMAIL="$1" git -c user.email="$1" commit -q --allow-empty -m "$2"
|
|
10
|
+
}
|
|
11
|
+
expect() { # pass|fail, what
|
|
12
|
+
if bash "$HERE/check-identity.sh" HEAD~1..HEAD >/dev/null 2>&1; then got=pass; else got=fail; fi
|
|
13
|
+
[ "$got" = "$1" ] && echo " ok - $2" || { echo " FAIL - $2 (got $got)"; fail=1; }
|
|
14
|
+
}
|
|
15
|
+
commit 5214595+ren-diao@users.noreply.github.com root
|
|
16
|
+
commit 5214595+ren-diao@users.noreply.github.com "$(printf 'x\n\nCo-authored-by: Claude Opus 5 <noreply@anthropic.com>')"
|
|
17
|
+
expect pass "the owner, with Claude as co-author"
|
|
18
|
+
commit 5214595+ren-diao@users.noreply.github.com "$(printf 'v\n\nCo-authored-by: Codex <codex@openai.com>')"
|
|
19
|
+
expect pass "the owner, with Codex as co-author"
|
|
20
|
+
commit 326013263+endario-ci[bot]@users.noreply.github.com y
|
|
21
|
+
expect pass "a bot"
|
|
22
|
+
commit 999+another-account@users.noreply.github.com z
|
|
23
|
+
expect fail "another of the owner's accounts, though its address is noreply"
|
|
24
|
+
commit 5214595+ren-diao@users.noreply.github.com "$(printf 'w\n\nCo-authored-by: q <999+another-account@users.noreply.github.com>')"
|
|
25
|
+
expect fail "another account credited as co-author"
|
|
26
|
+
exit $fail
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Identity
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
identity:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
- name: The check refuses what it should
|
|
19
|
+
run: bash .githooks/check-identity.test.sh
|
|
20
|
+
- name: Every new commit is the owner's or a bot's
|
|
21
|
+
env:
|
|
22
|
+
BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
|
23
|
+
HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
|
24
|
+
run: |
|
|
25
|
+
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] \
|
|
26
|
+
|| ! git cat-file -e "$BASE" 2>/dev/null; then
|
|
27
|
+
range="$HEAD_SHA~1..$HEAD_SHA"
|
|
28
|
+
else
|
|
29
|
+
range="$BASE..$HEAD_SHA"
|
|
30
|
+
fi
|
|
31
|
+
bash .githooks/check-identity.sh "$range"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
pypi:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write # PyPI trusted publishing: no token is stored anywhere
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v6
|
|
16
|
+
- run: uv build
|
|
17
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python: ["3.11", "3.12", "3.13"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with:
|
|
13
|
+
python-version: ${{ matrix.python }}
|
|
14
|
+
- run: PYTHONPATH=src python -m unittest discover -s tests -v
|
unlimited-0.0.9/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ren Diao
|
|
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.
|
unlimited-0.0.9/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: unlimited
|
|
3
|
+
Version: 0.0.9
|
|
4
|
+
Summary: Read AI-subscription quota usage, per window, as facts — and where each window is heading.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# unlimited
|
|
11
|
+
|
|
12
|
+
Read AI-subscription quota usage, per window, as facts — and where each window is heading.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
uv tool install unlimited # or: pipx install unlimited
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
unlimited # usage per account, for people
|
|
20
|
+
unlimited read [--vendor V]... [--max-age SECONDS] --json
|
|
21
|
+
unlimited capture claude-statusline # in a Claude Code statusline script
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Each reading gives each limit's window length, the fraction used so far, and its reset time. It
|
|
25
|
+
also gives whether the vendor says the limit is held, and why, and, where the vendor names it,
|
|
26
|
+
the account's plan. unlimited reports what the vendor says, and projects it forward: it never
|
|
27
|
+
picks an account or draws a threshold. Choosing what to do with a reading is up to the consumer.
|
|
28
|
+
|
|
29
|
+
## For people
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
## For agents
|
|
34
|
+
|
|
35
|
+
`unlimited read --json` gives the same readings, one per account, for load-balancing and pacing
|
|
36
|
+
work across subscriptions (one limit shown):
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
[
|
|
40
|
+
{
|
|
41
|
+
"schema": 1,
|
|
42
|
+
"vendor": "anthropic",
|
|
43
|
+
"account": "work",
|
|
44
|
+
"taken_at": "2026-09-21T14:00:00+00:00",
|
|
45
|
+
"source": "api",
|
|
46
|
+
"plan": "default_claude_max_20x",
|
|
47
|
+
"status": "ok",
|
|
48
|
+
"why": null,
|
|
49
|
+
"retry_until": null,
|
|
50
|
+
"limits": [
|
|
51
|
+
{
|
|
52
|
+
"name": "seven_day",
|
|
53
|
+
"window_minutes": 10080,
|
|
54
|
+
"used_at_least": 0.41,
|
|
55
|
+
"resets_at": "2026-09-25T11:00:00+00:00",
|
|
56
|
+
"held": false,
|
|
57
|
+
"held_why": null,
|
|
58
|
+
"severity": null,
|
|
59
|
+
"active": null,
|
|
60
|
+
"kind": null,
|
|
61
|
+
"projection": {
|
|
62
|
+
"at_reset": [0.873, 0.9436],
|
|
63
|
+
"exhausts_at": null,
|
|
64
|
+
"run_out": 0.164,
|
|
65
|
+
"samples": 2,
|
|
66
|
+
"past_windows": 5,
|
|
67
|
+
"since": "2026-09-20T14:00:00+00:00"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Read `projection.at_reset` against `used_at_least` rather than either alone, and
|
|
76
|
+
`past_windows`/`samples` for how much the projection rests on.
|
|
77
|
+
|
|
78
|
+
## Sources
|
|
79
|
+
|
|
80
|
+
| Vendor | Local, no network | Network |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| Anthropic (Claude Code, claude.ai sign-in) | the `rate_limits` Claude Code hands its statusline, via `capture` | `oauth/usage` on Claude Code's own token |
|
|
83
|
+
| OpenAI (Codex, ChatGPT sign-in) | `rate_limits` in Codex's session logs | `wham/usage` on Codex's own token |
|
|
84
|
+
| Z.ai (GLM coding plan) | — | `quota/limit` on the plan's API key |
|
|
85
|
+
| OpenCode Go | — | `zen/go/v1/usage` on the Go API key opencode keeps |
|
|
86
|
+
| xAI Grok (SuperGrok, Grok CLI sign-in) | — | the Grok CLI's billing proxy on its own token |
|
|
87
|
+
|
|
88
|
+
The network endpoints are not officially documented and may change without notice. unlimited
|
|
89
|
+
reads each harness's credential where the harness keeps it. It never refreshes a token or
|
|
90
|
+
writes a credential, and no token appears in its output, cache or errors.
|
|
91
|
+
|
|
92
|
+
## Projection
|
|
93
|
+
|
|
94
|
+
Each limit also carries a `projection`: where the window is heading, from the readings unlimited
|
|
95
|
+
has taken. Within the window, two paces are extended to the reset — the average since it opened,
|
|
96
|
+
and a recency-weighted pace with a half-life of a fourteenth of the window, which rises with a
|
|
97
|
+
burst and falls in a quiet spell. Past windows of the same limit, kept for
|
|
98
|
+
eight windows, add the shape of use (quiet nights, busy Mondays): each one's use from this point
|
|
99
|
+
to its end, shifted to today's. They take over from the paces between the third and eighth window.
|
|
100
|
+
|
|
101
|
+
`at_reset` is `[low, high]`, unclamped, so `1.07` means use would pass the limit. `exhausts_at`
|
|
102
|
+
is when the high end reaches it, if before the reset. `run_out` is how likely use passes it, from the past windows that did, or `null`
|
|
103
|
+
without them. `samples`, `past_windows` and `since` say what it rests on. A projection is only as good as how often something reads: a statusline
|
|
104
|
+
`capture` feeds it continuously.
|
|
105
|
+
|
|
106
|
+
## Cache
|
|
107
|
+
|
|
108
|
+
There is one cache file and one `flock` per vendor under `$XDG_CACHE_HOME/unlimited`, mode
|
|
109
|
+
`0600`. Concurrent callers share one upstream request. A 429's `Retry-After` holds off every
|
|
110
|
+
caller until it passes, capped at 24 hours; a 429 or 5xx holds off at least five minutes, and the
|
|
111
|
+
account's last good reading stands in the meantime. On macOS the keychain is readable only from the
|
|
112
|
+
user's GUI session or launchd, not over plain ssh.
|
|
113
|
+
|
|
114
|
+
## Status
|
|
115
|
+
|
|
116
|
+
Pre-release. The schema (`"schema": 1`) may still change.
|
|
117
|
+
|
|
118
|
+
## 2mw2lt
|
|
119
|
+
|
|
120
|
+
unlimited is part of [2mw2lt](https://2mw2lt.com) — *Too Much Work, Too Little Time* — a steering
|
|
121
|
+
partner that coordinates work across AI workers and trusted people. 2mw2lt reads its accounts
|
|
122
|
+
through unlimited.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# unlimited
|
|
2
|
+
|
|
3
|
+
Read AI-subscription quota usage, per window, as facts — and where each window is heading.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
uv tool install unlimited # or: pipx install unlimited
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
unlimited # usage per account, for people
|
|
11
|
+
unlimited read [--vendor V]... [--max-age SECONDS] --json
|
|
12
|
+
unlimited capture claude-statusline # in a Claude Code statusline script
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Each reading gives each limit's window length, the fraction used so far, and its reset time. It
|
|
16
|
+
also gives whether the vendor says the limit is held, and why, and, where the vendor names it,
|
|
17
|
+
the account's plan. unlimited reports what the vendor says, and projects it forward: it never
|
|
18
|
+
picks an account or draws a threshold. Choosing what to do with a reading is up to the consumer.
|
|
19
|
+
|
|
20
|
+
## For people
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
## For agents
|
|
25
|
+
|
|
26
|
+
`unlimited read --json` gives the same readings, one per account, for load-balancing and pacing
|
|
27
|
+
work across subscriptions (one limit shown):
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
[
|
|
31
|
+
{
|
|
32
|
+
"schema": 1,
|
|
33
|
+
"vendor": "anthropic",
|
|
34
|
+
"account": "work",
|
|
35
|
+
"taken_at": "2026-09-21T14:00:00+00:00",
|
|
36
|
+
"source": "api",
|
|
37
|
+
"plan": "default_claude_max_20x",
|
|
38
|
+
"status": "ok",
|
|
39
|
+
"why": null,
|
|
40
|
+
"retry_until": null,
|
|
41
|
+
"limits": [
|
|
42
|
+
{
|
|
43
|
+
"name": "seven_day",
|
|
44
|
+
"window_minutes": 10080,
|
|
45
|
+
"used_at_least": 0.41,
|
|
46
|
+
"resets_at": "2026-09-25T11:00:00+00:00",
|
|
47
|
+
"held": false,
|
|
48
|
+
"held_why": null,
|
|
49
|
+
"severity": null,
|
|
50
|
+
"active": null,
|
|
51
|
+
"kind": null,
|
|
52
|
+
"projection": {
|
|
53
|
+
"at_reset": [0.873, 0.9436],
|
|
54
|
+
"exhausts_at": null,
|
|
55
|
+
"run_out": 0.164,
|
|
56
|
+
"samples": 2,
|
|
57
|
+
"past_windows": 5,
|
|
58
|
+
"since": "2026-09-20T14:00:00+00:00"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Read `projection.at_reset` against `used_at_least` rather than either alone, and
|
|
67
|
+
`past_windows`/`samples` for how much the projection rests on.
|
|
68
|
+
|
|
69
|
+
## Sources
|
|
70
|
+
|
|
71
|
+
| Vendor | Local, no network | Network |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| Anthropic (Claude Code, claude.ai sign-in) | the `rate_limits` Claude Code hands its statusline, via `capture` | `oauth/usage` on Claude Code's own token |
|
|
74
|
+
| OpenAI (Codex, ChatGPT sign-in) | `rate_limits` in Codex's session logs | `wham/usage` on Codex's own token |
|
|
75
|
+
| Z.ai (GLM coding plan) | — | `quota/limit` on the plan's API key |
|
|
76
|
+
| OpenCode Go | — | `zen/go/v1/usage` on the Go API key opencode keeps |
|
|
77
|
+
| xAI Grok (SuperGrok, Grok CLI sign-in) | — | the Grok CLI's billing proxy on its own token |
|
|
78
|
+
|
|
79
|
+
The network endpoints are not officially documented and may change without notice. unlimited
|
|
80
|
+
reads each harness's credential where the harness keeps it. It never refreshes a token or
|
|
81
|
+
writes a credential, and no token appears in its output, cache or errors.
|
|
82
|
+
|
|
83
|
+
## Projection
|
|
84
|
+
|
|
85
|
+
Each limit also carries a `projection`: where the window is heading, from the readings unlimited
|
|
86
|
+
has taken. Within the window, two paces are extended to the reset — the average since it opened,
|
|
87
|
+
and a recency-weighted pace with a half-life of a fourteenth of the window, which rises with a
|
|
88
|
+
burst and falls in a quiet spell. Past windows of the same limit, kept for
|
|
89
|
+
eight windows, add the shape of use (quiet nights, busy Mondays): each one's use from this point
|
|
90
|
+
to its end, shifted to today's. They take over from the paces between the third and eighth window.
|
|
91
|
+
|
|
92
|
+
`at_reset` is `[low, high]`, unclamped, so `1.07` means use would pass the limit. `exhausts_at`
|
|
93
|
+
is when the high end reaches it, if before the reset. `run_out` is how likely use passes it, from the past windows that did, or `null`
|
|
94
|
+
without them. `samples`, `past_windows` and `since` say what it rests on. A projection is only as good as how often something reads: a statusline
|
|
95
|
+
`capture` feeds it continuously.
|
|
96
|
+
|
|
97
|
+
## Cache
|
|
98
|
+
|
|
99
|
+
There is one cache file and one `flock` per vendor under `$XDG_CACHE_HOME/unlimited`, mode
|
|
100
|
+
`0600`. Concurrent callers share one upstream request. A 429's `Retry-After` holds off every
|
|
101
|
+
caller until it passes, capped at 24 hours; a 429 or 5xx holds off at least five minutes, and the
|
|
102
|
+
account's last good reading stands in the meantime. On macOS the keychain is readable only from the
|
|
103
|
+
user's GUI session or launchd, not over plain ssh.
|
|
104
|
+
|
|
105
|
+
## Status
|
|
106
|
+
|
|
107
|
+
Pre-release. The schema (`"schema": 1`) may still change.
|
|
108
|
+
|
|
109
|
+
## 2mw2lt
|
|
110
|
+
|
|
111
|
+
unlimited is part of [2mw2lt](https://2mw2lt.com) — *Too Much Work, Too Little Time* — a steering
|
|
112
|
+
partner that coordinates work across AI workers and trusted people. 2mw2lt reads its accounts
|
|
113
|
+
through unlimited.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Regenerates the README's previews from fixed readings:
|
|
2
|
+
TZ=UTC uv run --with rich python docs/preview.py
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
13
|
+
from rich.console import Console # noqa: E402
|
|
14
|
+
from rich.text import Text # noqa: E402
|
|
15
|
+
|
|
16
|
+
from unlimited import projection, show # noqa: E402
|
|
17
|
+
from unlimited.schema import limit, reading # noqa: E402
|
|
18
|
+
|
|
19
|
+
NOW = datetime(2026, 9, 21, 14, 0, tzinfo=timezone.utc)
|
|
20
|
+
H, D = timedelta(hours=1), timedelta(days=1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def win(account, name, minutes, resets, pairs, past=()):
|
|
24
|
+
"""History for one window: (time before NOW, used) pairs, plus past windows' curves."""
|
|
25
|
+
samples = [[(NOW - ago).isoformat(), u, resets.isoformat()] for ago, u in pairs]
|
|
26
|
+
return {f"{account}\t{name}": {"samples": samples, "window_minutes": minutes, "past": [
|
|
27
|
+
{"resets_at": (resets - timedelta(minutes=minutes) * (len(past) - i)).isoformat(),
|
|
28
|
+
"curve": [round(f(x / projection.GRID), 4) for x in range(projection.GRID + 1)]}
|
|
29
|
+
for i, f in enumerate(past)]}}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
claude_week, codex_week = NOW + 3 * D + 21 * H, NOW + 2 * D + 5 * H
|
|
33
|
+
history = {
|
|
34
|
+
**win("work", "five_hour", 300, NOW + 2 * H, [(2 * H, 0.08), (H, 0.2), (0 * H, 0.31)]),
|
|
35
|
+
**win("work", "seven_day", 10080, claude_week, [(D, 0.33), (0 * H, 0.41)],
|
|
36
|
+
past=[lambda x: min(0.95 * x ** 1.1, 1)] * 5),
|
|
37
|
+
**win("codex", "codex", 10080, codex_week, [(D, 0.45), (6 * H, 0.62), (0 * H, 0.7)]),
|
|
38
|
+
}
|
|
39
|
+
readings = [
|
|
40
|
+
reading("anthropic", "work", NOW, "ok", plan="default_claude_max_20x", limits=[
|
|
41
|
+
limit("five_hour", window_minutes=300, used_at_least=0.31, resets_at=NOW + 2 * H, held=False),
|
|
42
|
+
limit("seven_day", window_minutes=10080, used_at_least=0.41, resets_at=claude_week, held=False)]),
|
|
43
|
+
reading("openai", "codex", NOW - 7 * 60 * timedelta(seconds=1), "ok", plan="pro", source="session-log", limits=[
|
|
44
|
+
limit("codex", window_minutes=10080, used_at_least=0.7, resets_at=codex_week, held=False)]),
|
|
45
|
+
dict(reading("zai", "glm", NOW - 12 * 60 * timedelta(seconds=1), "ok", plan="max", limits=[
|
|
46
|
+
limit("five_hour", window_minutes=300, used_at_least=0.92, resets_at=NOW + 40 * 60 * timedelta(seconds=1),
|
|
47
|
+
held=True, held_why="limit_reached")]),
|
|
48
|
+
retry_until=(NOW + 3 * 60 * timedelta(seconds=1)).isoformat()),
|
|
49
|
+
]
|
|
50
|
+
readings = [projection.attach(r, history) for r in readings]
|
|
51
|
+
show._claude_dirs = lambda: {"work": "work"}
|
|
52
|
+
|
|
53
|
+
out = Path(__file__).parent
|
|
54
|
+
console = Console(record=True, width=104, file=open("/dev/null", "w"))
|
|
55
|
+
console.print(Text.from_ansi("$ unlimited\n" + show.render(readings, NOW, color=True).rstrip()))
|
|
56
|
+
(out / "status.svg").write_text(console.export_svg(title="unlimited"))
|
|
57
|
+
print(json.dumps([dict(readings[0], limits=readings[0]["limits"][1:])], indent=2)) # the README's JSON
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<svg class="rich-terminal" viewBox="0 0 1287 367.2" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<!-- Generated with Rich https://www.textualize.io -->
|
|
3
|
+
<style>
|
|
4
|
+
|
|
5
|
+
@font-face {
|
|
6
|
+
font-family: "Fira Code";
|
|
7
|
+
src: local("FiraCode-Regular"),
|
|
8
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
|
9
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
|
10
|
+
font-style: normal;
|
|
11
|
+
font-weight: 400;
|
|
12
|
+
}
|
|
13
|
+
@font-face {
|
|
14
|
+
font-family: "Fira Code";
|
|
15
|
+
src: local("FiraCode-Bold"),
|
|
16
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
|
17
|
+
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
|
18
|
+
font-style: bold;
|
|
19
|
+
font-weight: 700;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.terminal-415471781-matrix {
|
|
23
|
+
font-family: Fira Code, monospace;
|
|
24
|
+
font-size: 20px;
|
|
25
|
+
line-height: 24.4px;
|
|
26
|
+
font-variant-east-asian: full-width;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.terminal-415471781-title {
|
|
30
|
+
font-size: 18px;
|
|
31
|
+
font-weight: bold;
|
|
32
|
+
font-family: arial;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.terminal-415471781-r1 { fill: #c5c8c6 }
|
|
36
|
+
.terminal-415471781-r2 { fill: #98a84b }
|
|
37
|
+
.terminal-415471781-r3 { fill: #cc555a }
|
|
38
|
+
</style>
|
|
39
|
+
|
|
40
|
+
<defs>
|
|
41
|
+
<clipPath id="terminal-415471781-clip-terminal">
|
|
42
|
+
<rect x="0" y="0" width="1267.8" height="316.2" />
|
|
43
|
+
</clipPath>
|
|
44
|
+
<clipPath id="terminal-415471781-line-0">
|
|
45
|
+
<rect x="0" y="1.5" width="1268.8" height="24.65"/>
|
|
46
|
+
</clipPath>
|
|
47
|
+
<clipPath id="terminal-415471781-line-1">
|
|
48
|
+
<rect x="0" y="25.9" width="1268.8" height="24.65"/>
|
|
49
|
+
</clipPath>
|
|
50
|
+
<clipPath id="terminal-415471781-line-2">
|
|
51
|
+
<rect x="0" y="50.3" width="1268.8" height="24.65"/>
|
|
52
|
+
</clipPath>
|
|
53
|
+
<clipPath id="terminal-415471781-line-3">
|
|
54
|
+
<rect x="0" y="74.7" width="1268.8" height="24.65"/>
|
|
55
|
+
</clipPath>
|
|
56
|
+
<clipPath id="terminal-415471781-line-4">
|
|
57
|
+
<rect x="0" y="99.1" width="1268.8" height="24.65"/>
|
|
58
|
+
</clipPath>
|
|
59
|
+
<clipPath id="terminal-415471781-line-5">
|
|
60
|
+
<rect x="0" y="123.5" width="1268.8" height="24.65"/>
|
|
61
|
+
</clipPath>
|
|
62
|
+
<clipPath id="terminal-415471781-line-6">
|
|
63
|
+
<rect x="0" y="147.9" width="1268.8" height="24.65"/>
|
|
64
|
+
</clipPath>
|
|
65
|
+
<clipPath id="terminal-415471781-line-7">
|
|
66
|
+
<rect x="0" y="172.3" width="1268.8" height="24.65"/>
|
|
67
|
+
</clipPath>
|
|
68
|
+
<clipPath id="terminal-415471781-line-8">
|
|
69
|
+
<rect x="0" y="196.7" width="1268.8" height="24.65"/>
|
|
70
|
+
</clipPath>
|
|
71
|
+
<clipPath id="terminal-415471781-line-9">
|
|
72
|
+
<rect x="0" y="221.1" width="1268.8" height="24.65"/>
|
|
73
|
+
</clipPath>
|
|
74
|
+
<clipPath id="terminal-415471781-line-10">
|
|
75
|
+
<rect x="0" y="245.5" width="1268.8" height="24.65"/>
|
|
76
|
+
</clipPath>
|
|
77
|
+
<clipPath id="terminal-415471781-line-11">
|
|
78
|
+
<rect x="0" y="269.9" width="1268.8" height="24.65"/>
|
|
79
|
+
</clipPath>
|
|
80
|
+
</defs>
|
|
81
|
+
|
|
82
|
+
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1285" height="365.2" rx="8"/><text class="terminal-415471781-title" fill="#c5c8c6" text-anchor="middle" x="642" y="27">unlimited</text>
|
|
83
|
+
<g transform="translate(26,22)">
|
|
84
|
+
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
|
85
|
+
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
|
86
|
+
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
|
87
|
+
</g>
|
|
88
|
+
|
|
89
|
+
<g transform="translate(9, 41)" clip-path="url(#terminal-415471781-clip-terminal)">
|
|
90
|
+
|
|
91
|
+
<g class="terminal-415471781-matrix">
|
|
92
|
+
<text class="terminal-415471781-r1" x="0" y="20" textLength="134.2" clip-path="url(#terminal-415471781-line-0)">$ unlimited</text><text class="terminal-415471781-r1" x="1268.8" y="20" textLength="12.2" clip-path="url(#terminal-415471781-line-0)">
|
|
93
|
+
</text><text class="terminal-415471781-r1" x="0" y="44.4" textLength="256.2" clip-path="url(#terminal-415471781-line-1)">Claude Max 20x · work</text><text class="terminal-415471781-r1" x="1268.8" y="44.4" textLength="12.2" clip-path="url(#terminal-415471781-line-1)">
|
|
94
|
+
</text><text class="terminal-415471781-r1" x="0" y="68.8" textLength="219.6" clip-path="url(#terminal-415471781-line-2)">  5-hour          </text><text class="terminal-415471781-r2" x="219.6" y="68.8" textLength="329.4" clip-path="url(#terminal-415471781-line-2)">██████░░░░░░░░░░░░░░  31.0%</text><text class="terminal-415471781-r1" x="549" y="68.8" textLength="390.4" clip-path="url(#terminal-415471781-line-2)">  resets in  2h 00m  (Mon 16:00)</text><text class="terminal-415471781-r1" x="1268.8" y="68.8" textLength="12.2" clip-path="url(#terminal-415471781-line-2)">
|
|
95
|
+
</text><text class="terminal-415471781-r1" x="0" y="93.2" textLength="244" clip-path="url(#terminal-415471781-line-3)">                  → </text><text class="terminal-415471781-r2" x="244" y="93.2" textLength="73.2" clip-path="url(#terminal-415471781-line-3)">52–53%</text><text class="terminal-415471781-r1" x="317.2" y="93.2" textLength="109.8" clip-path="url(#terminal-415471781-line-3)"> at reset</text><text class="terminal-415471781-r1" x="1268.8" y="93.2" textLength="12.2" clip-path="url(#terminal-415471781-line-3)">
|
|
96
|
+
</text><text class="terminal-415471781-r1" x="0" y="117.6" textLength="219.6" clip-path="url(#terminal-415471781-line-4)">  weekly          </text><text class="terminal-415471781-r2" x="219.6" y="117.6" textLength="329.4" clip-path="url(#terminal-415471781-line-4)">████████░░░░░░░░░░░░  41.0%</text><text class="terminal-415471781-r1" x="549" y="117.6" textLength="390.4" clip-path="url(#terminal-415471781-line-4)">  resets in  3d 21h  (Fri 11:00)</text><text class="terminal-415471781-r1" x="1268.8" y="117.6" textLength="12.2" clip-path="url(#terminal-415471781-line-4)">
|
|
97
|
+
</text><text class="terminal-415471781-r1" x="0" y="142" textLength="244" clip-path="url(#terminal-415471781-line-5)">                  → </text><text class="terminal-415471781-r3" x="244" y="142" textLength="73.2" clip-path="url(#terminal-415471781-line-5)">87–94%</text><text class="terminal-415471781-r1" x="317.2" y="142" textLength="719.8" clip-path="url(#terminal-415471781-line-5)"> at reset · 16% chance of running out · from 5 past windows</text><text class="terminal-415471781-r1" x="1268.8" y="142" textLength="12.2" clip-path="url(#terminal-415471781-line-5)">
|
|
98
|
+
</text><text class="terminal-415471781-r1" x="1268.8" y="166.4" textLength="12.2" clip-path="url(#terminal-415471781-line-6)">
|
|
99
|
+
</text><text class="terminal-415471781-r1" x="0" y="190.8" textLength="549" clip-path="url(#terminal-415471781-line-7)">Codex Pro · codex  (read 7m ago, session-log)</text><text class="terminal-415471781-r1" x="1268.8" y="190.8" textLength="12.2" clip-path="url(#terminal-415471781-line-7)">
|
|
100
|
+
</text><text class="terminal-415471781-r1" x="0" y="215.2" textLength="219.6" clip-path="url(#terminal-415471781-line-8)">  weekly          </text><text class="terminal-415471781-r2" x="219.6" y="215.2" textLength="329.4" clip-path="url(#terminal-415471781-line-8)">██████████████░░░░░░  70.0%</text><text class="terminal-415471781-r1" x="549" y="215.2" textLength="390.4" clip-path="url(#terminal-415471781-line-8)">  resets in   2d 5h  (Wed 19:00)</text><text class="terminal-415471781-r1" x="1268.8" y="215.2" textLength="12.2" clip-path="url(#terminal-415471781-line-8)">
|
|
101
|
+
</text><text class="terminal-415471781-r1" x="0" y="239.6" textLength="244" clip-path="url(#terminal-415471781-line-9)">                  → </text><text class="terminal-415471781-r3" x="244" y="239.6" textLength="97.6" clip-path="url(#terminal-415471781-line-9)">102–125%</text><text class="terminal-415471781-r1" x="341.6" y="239.6" textLength="500.2" clip-path="url(#terminal-415471781-line-9)"> at reset · runs out Tue 19:07 (in 1d 5h)</text><text class="terminal-415471781-r1" x="1268.8" y="239.6" textLength="12.2" clip-path="url(#terminal-415471781-line-9)">
|
|
102
|
+
</text><text class="terminal-415471781-r1" x="1268.8" y="264" textLength="12.2" clip-path="url(#terminal-415471781-line-10)">
|
|
103
|
+
</text><text class="terminal-415471781-r1" x="0" y="288.4" textLength="756.4" clip-path="url(#terminal-415471781-line-11)">Z.ai GLM Max · glm  (read 12m ago, throttled: next read in 3m)</text><text class="terminal-415471781-r1" x="1268.8" y="288.4" textLength="12.2" clip-path="url(#terminal-415471781-line-11)">
|
|
104
|
+
</text><text class="terminal-415471781-r1" x="0" y="312.8" textLength="219.6" clip-path="url(#terminal-415471781-line-12)">  5-hour          </text><text class="terminal-415471781-r3" x="219.6" y="312.8" textLength="329.4" clip-path="url(#terminal-415471781-line-12)">██████████████████░░  92.0%</text><text class="terminal-415471781-r1" x="549" y="312.8" textLength="646.6" clip-path="url(#terminal-415471781-line-12)">  resets in     40m  (Mon 14:40)  HELD: limit_reached</text><text class="terminal-415471781-r1" x="1268.8" y="312.8" textLength="12.2" clip-path="url(#terminal-415471781-line-12)">
|
|
105
|
+
</text>
|
|
106
|
+
</g>
|
|
107
|
+
</g>
|
|
108
|
+
</svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "unlimited"
|
|
3
|
+
version = "0.0.9"
|
|
4
|
+
description = "Read AI-subscription quota usage, per window, as facts — and where each window is heading."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[project.scripts]
|
|
11
|
+
unlimited = "unlimited.cli:main"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Read AI-subscription quota usage, per window, as facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from . import cache, transport
|
|
8
|
+
from .adapters import REGISTRY
|
|
9
|
+
|
|
10
|
+
VENDORS = tuple(sorted(REGISTRY))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read(vendors: list[str] | None = None, *, max_age: float = 300.0) -> list[dict]:
|
|
14
|
+
"""Readings for each vendor's accounts on this machine, through the shared cache."""
|
|
15
|
+
out = []
|
|
16
|
+
for v in vendors or VENDORS:
|
|
17
|
+
out += cache.through(REGISTRY[v], max_age=max_age,
|
|
18
|
+
clock=lambda: datetime.now(timezone.utc), get=transport.get)
|
|
19
|
+
return out
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def accounts(vendor: str) -> list[str]:
|
|
23
|
+
"""The account ids this machine can read for one vendor, without asking the vendor."""
|
|
24
|
+
return [c.account for c in REGISTRY[vendor].discover() if c.account]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Vendor adapters. Each exposes `VENDOR`, `discover() -> list[Credential]` and
|
|
2
|
+
`read(cred, now, get) -> reading`, where `get(url, headers, now)` returns a transport.Answer."""
|
|
3
|
+
|
|
4
|
+
from . import anthropic, opencode, openai, xai, zai
|
|
5
|
+
|
|
6
|
+
REGISTRY = {m.VENDOR: m for m in (anthropic, opencode, openai, xai, zai)}
|