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.
@@ -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
@@ -0,0 +1,2 @@
1
+ __pycache__/
2
+ *.egg-info/
@@ -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.
@@ -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
+ ![unlimited status: one block per account, a bar per usage window, and a forecast line under each](docs/status.svg)
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
+ ![unlimited status: one block per account, a bar per usage window, and a forecast line under each](docs/status.svg)
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)">$&#160;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&#160;Max&#160;20x&#160;·&#160;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)">&#160;&#160;5-hour&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-415471781-r2" x="219.6" y="68.8" textLength="329.4" clip-path="url(#terminal-415471781-line-2)">██████░░░░░░░░░░░░░░&#160;&#160;31.0%</text><text class="terminal-415471781-r1" x="549" y="68.8" textLength="390.4" clip-path="url(#terminal-415471781-line-2)">&#160;&#160;resets&#160;in&#160;&#160;2h&#160;00m&#160;&#160;(Mon&#160;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)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;→&#160;</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)">&#160;at&#160;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)">&#160;&#160;weekly&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-415471781-r2" x="219.6" y="117.6" textLength="329.4" clip-path="url(#terminal-415471781-line-4)">████████░░░░░░░░░░░░&#160;&#160;41.0%</text><text class="terminal-415471781-r1" x="549" y="117.6" textLength="390.4" clip-path="url(#terminal-415471781-line-4)">&#160;&#160;resets&#160;in&#160;&#160;3d&#160;21h&#160;&#160;(Fri&#160;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)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;→&#160;</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)">&#160;at&#160;reset&#160;·&#160;16%&#160;chance&#160;of&#160;running&#160;out&#160;·&#160;from&#160;5&#160;past&#160;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&#160;Pro&#160;·&#160;codex&#160;&#160;(read&#160;7m&#160;ago,&#160;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)">&#160;&#160;weekly&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-415471781-r2" x="219.6" y="215.2" textLength="329.4" clip-path="url(#terminal-415471781-line-8)">██████████████░░░░░░&#160;&#160;70.0%</text><text class="terminal-415471781-r1" x="549" y="215.2" textLength="390.4" clip-path="url(#terminal-415471781-line-8)">&#160;&#160;resets&#160;in&#160;&#160;&#160;2d&#160;5h&#160;&#160;(Wed&#160;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)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;→&#160;</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)">&#160;at&#160;reset&#160;·&#160;runs&#160;out&#160;Tue&#160;19:07&#160;(in&#160;1d&#160;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&#160;GLM&#160;Max&#160;·&#160;glm&#160;&#160;(read&#160;12m&#160;ago,&#160;throttled:&#160;next&#160;read&#160;in&#160;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)">&#160;&#160;5-hour&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-415471781-r3" x="219.6" y="312.8" textLength="329.4" clip-path="url(#terminal-415471781-line-12)">██████████████████░░&#160;&#160;92.0%</text><text class="terminal-415471781-r1" x="549" y="312.8" textLength="646.6" clip-path="url(#terminal-415471781-line-12)">&#160;&#160;resets&#160;in&#160;&#160;&#160;&#160;&#160;40m&#160;&#160;(Mon&#160;14:40)&#160;&#160;HELD:&#160;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)}