clanker-usage 0.4.2__tar.gz → 0.4.4__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.
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/PKG-INFO +8 -2
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/README.md +7 -1
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/build_wrappers.py +5 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/pyproject.toml +1 -1
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/cli.py +10 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/paths.py +16 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/__init__.py +2 -2
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/agy.py +2 -9
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/codex.py +2 -3
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/devin.py +113 -7
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/gemini.py +2 -3
- clanker_usage-0.4.4/src/clanker_usage/providers/muse.py +93 -0
- clanker_usage-0.4.4/src/clanker_usage/shapes/devin/devin-2026-10-24-rate-limits.json +29 -0
- clanker_usage-0.4.4/src/clanker_usage/shapes/muse-code/muse-2026-10-25.json +17 -0
- clanker_usage-0.4.4/src/clanker_usage/shapes/opencode/opencode-2026-10-24-idle-5h.json +30 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/tests/test_zcode.py +1 -1
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/.gitignore +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/LICENSE +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/__init__.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/__main__.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/cache.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/errors.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/http.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/oauth.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/claude.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/cursor.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/deepseek.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/google.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/opencode.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/zcode.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/agy/agy-2026-10-19.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/claude/claude-2026-10-19.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/codex/api-2026-10-19.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/codex/app-server-2026-10-19.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/cursor/cursor-2026-10-19.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/deepseek/deepseek-2026-10-19.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/devin/devin-2026-10-19.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/gemini/from-gemini-cli-0.56-source.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/opencode/opencode-2026-10-19.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/zcode/no-plan-2026-10-18.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/zcode/quota-2026-09-05.json +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/tests/test_core.py +0 -0
- {clanker_usage-0.4.2 → clanker_usage-0.4.4}/tests/test_install.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: clanker-usage
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Plan and quota of coding-agent accounts, with a statusline, a cache daemon and a check for provider changes
|
|
5
5
|
Project-URL: Homepage, https://github.com/wakamex/clanker-usage
|
|
6
6
|
Project-URL: Source, https://github.com/wakamex/clanker-usage
|
|
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
Monitor the plan and quota of your coding-agent accounts from the terminal: current usage, a one-line statusline, a daemon that keeps a cache fresh, and a check that fails loudly when a provider changes its API. Every provider has the same commands and the same JSON document.
|
|
18
18
|
|
|
19
|
-
Providers: Antigravity (`agy-usage`), Claude Code (`ccusage`), Codex (`codex-cli-usage`), Cursor (`cursor-cli-usage`), DeepSeek (`deepseek-harness-usage`), Devin (`devin-cli-usage`), Gemini CLI (`gemini-cli-usage`), OpenCode Go (`opencode-cli-usage`) and Z.ai's Coding Plan (`zcode-cli-usage`). Each package name and command is a former standalone tool, now one interface over one codebase.
|
|
19
|
+
Providers: Antigravity (`agy-usage`), Claude Code (`ccusage`), Codex (`codex-cli-usage`), Cursor (`cursor-cli-usage`), DeepSeek (`deepseek-harness-usage`), Devin (`devin-cli-usage`), Gemini CLI (`gemini-cli-usage`), Muse Code (`muse-code-usage`), OpenCode Go (`opencode-cli-usage`) and Z.ai's Coding Plan (`zcode-cli-usage`). Each package name and command is a former standalone tool, now one interface over one codebase.
|
|
20
20
|
|
|
21
21
|
clanker-usage reports what the provider says about the account: plan, quota windows and balance. Token use and cost from local session logs are the job of [clanker-analytics](https://github.com/wakamex/clanker-analytics).
|
|
22
22
|
|
|
@@ -158,12 +158,18 @@ Sign in with the Devin CLI. The tool reads its local API key and server from `~/
|
|
|
158
158
|
|
|
159
159
|
Usage comes from the server's `SeatManagementService/GetUserStatus`. The `daily` and `weekly` windows come from the remaining quota it reports; `details.credits` keeps the prompt, flow and flex credits and the overage balance. The tool keeps only the plan and quota parts of the response, leaving out the account's name, email and model lists.
|
|
160
160
|
|
|
161
|
+
Devin's free models also have a rate limit that this API does not report. When a request hits it, Devin writes the error, with the time the limit resets, to its CLI log, so the tool reads the logs from the last seven days in `~/.local/share/devin/cli/logs`. `details.free_model_limits` lists, per model, the last hit, its reset and whether the limit still applied when the document was made, and `details.free_model_limit_hits` keeps the last 50 hits. `status` and `statusline` show a limit that still applies. The model is known only when the log names it, as Devin's interactive mode does; logs from its ACP server do not, so those hits have a null `model`. To read the logs of other Devin homes, such as ones another tool creates for each run, add a glob once: `devin-cli-usage configure --log-glob '~/work/*/devin/cli/logs'`.
|
|
162
|
+
|
|
161
163
|
## Gemini CLI
|
|
162
164
|
|
|
163
165
|
Quota exists only for a Google login, and since 2026-06-18 Google allows that only for [Gemini Code Assist](https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals) Standard and Enterprise subscriptions. Individual, Google AI Pro and Ultra accounts can no longer sign Gemini CLI in with Google; their quota is Antigravity's, which `agy-usage` reports. A Gemini API key has no quota to read. The tool chooses the login as Gemini CLI does: the `GOOGLE_GENAI_USE_GCA`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_API_KEY` and Cloud Shell variables, then `.gemini/settings.json` in the current directory, then `~/.gemini/settings.json` (under `$GEMINI_CLI_HOME` when set). With any other login the document is `unavailable` and `check` reports `expected-unavailable`, which does not fail it. With a Google login it reads `~/.gemini/oauth_creds.json` and renews an expired token the same way as for Antigravity, using the OAuth client in the credentials file or in the installed Gemini CLI.
|
|
164
166
|
|
|
165
167
|
Quota comes from Code Assist `retrieveUserQuota`, with one window per model, scoped to that model. `GOOGLE_CLOUD_PROJECT` selects the Code Assist project for accounts that need one, as in Gemini CLI.
|
|
166
168
|
|
|
169
|
+
## Muse Code
|
|
170
|
+
|
|
171
|
+
Sign in with Muse Code (`/login`). The tool reads Muse's Meta login from `~/.config/muse/auth.json` (under `$XDG_CONFIG_HOME` when set) and never changes it. Muse reports its usage only when it signs in and at the end of each model call, so the tool makes the sign-in's key request, `POST https://api.meta.ai/muse-code/key`, which uses no quota and, as checked with Muse 1.4.12, returns the key Muse already stored. The windows are the 5-hour window and the weekly limit, and the plan is the subscription tier, such as Muse Code Power Usage. The account id is a fingerprint of the account's email.
|
|
172
|
+
|
|
167
173
|
## OpenCode Go
|
|
168
174
|
|
|
169
175
|
Connect OpenCode Go in OpenCode with `/connect`. The tool reads the Go key OpenCode stores in `~/.local/share/opencode/auth.json` (under `$XDG_DATA_HOME` when set) and never changes the file. The account id is a fingerprint of the key.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Monitor the plan and quota of your coding-agent accounts from the terminal: current usage, a one-line statusline, a daemon that keeps a cache fresh, and a check that fails loudly when a provider changes its API. Every provider has the same commands and the same JSON document.
|
|
4
4
|
|
|
5
|
-
Providers: Antigravity (`agy-usage`), Claude Code (`ccusage`), Codex (`codex-cli-usage`), Cursor (`cursor-cli-usage`), DeepSeek (`deepseek-harness-usage`), Devin (`devin-cli-usage`), Gemini CLI (`gemini-cli-usage`), OpenCode Go (`opencode-cli-usage`) and Z.ai's Coding Plan (`zcode-cli-usage`). Each package name and command is a former standalone tool, now one interface over one codebase.
|
|
5
|
+
Providers: Antigravity (`agy-usage`), Claude Code (`ccusage`), Codex (`codex-cli-usage`), Cursor (`cursor-cli-usage`), DeepSeek (`deepseek-harness-usage`), Devin (`devin-cli-usage`), Gemini CLI (`gemini-cli-usage`), Muse Code (`muse-code-usage`), OpenCode Go (`opencode-cli-usage`) and Z.ai's Coding Plan (`zcode-cli-usage`). Each package name and command is a former standalone tool, now one interface over one codebase.
|
|
6
6
|
|
|
7
7
|
clanker-usage reports what the provider says about the account: plan, quota windows and balance. Token use and cost from local session logs are the job of [clanker-analytics](https://github.com/wakamex/clanker-analytics).
|
|
8
8
|
|
|
@@ -144,12 +144,18 @@ Sign in with the Devin CLI. The tool reads its local API key and server from `~/
|
|
|
144
144
|
|
|
145
145
|
Usage comes from the server's `SeatManagementService/GetUserStatus`. The `daily` and `weekly` windows come from the remaining quota it reports; `details.credits` keeps the prompt, flow and flex credits and the overage balance. The tool keeps only the plan and quota parts of the response, leaving out the account's name, email and model lists.
|
|
146
146
|
|
|
147
|
+
Devin's free models also have a rate limit that this API does not report. When a request hits it, Devin writes the error, with the time the limit resets, to its CLI log, so the tool reads the logs from the last seven days in `~/.local/share/devin/cli/logs`. `details.free_model_limits` lists, per model, the last hit, its reset and whether the limit still applied when the document was made, and `details.free_model_limit_hits` keeps the last 50 hits. `status` and `statusline` show a limit that still applies. The model is known only when the log names it, as Devin's interactive mode does; logs from its ACP server do not, so those hits have a null `model`. To read the logs of other Devin homes, such as ones another tool creates for each run, add a glob once: `devin-cli-usage configure --log-glob '~/work/*/devin/cli/logs'`.
|
|
148
|
+
|
|
147
149
|
## Gemini CLI
|
|
148
150
|
|
|
149
151
|
Quota exists only for a Google login, and since 2026-06-18 Google allows that only for [Gemini Code Assist](https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals) Standard and Enterprise subscriptions. Individual, Google AI Pro and Ultra accounts can no longer sign Gemini CLI in with Google; their quota is Antigravity's, which `agy-usage` reports. A Gemini API key has no quota to read. The tool chooses the login as Gemini CLI does: the `GOOGLE_GENAI_USE_GCA`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_API_KEY` and Cloud Shell variables, then `.gemini/settings.json` in the current directory, then `~/.gemini/settings.json` (under `$GEMINI_CLI_HOME` when set). With any other login the document is `unavailable` and `check` reports `expected-unavailable`, which does not fail it. With a Google login it reads `~/.gemini/oauth_creds.json` and renews an expired token the same way as for Antigravity, using the OAuth client in the credentials file or in the installed Gemini CLI.
|
|
150
152
|
|
|
151
153
|
Quota comes from Code Assist `retrieveUserQuota`, with one window per model, scoped to that model. `GOOGLE_CLOUD_PROJECT` selects the Code Assist project for accounts that need one, as in Gemini CLI.
|
|
152
154
|
|
|
155
|
+
## Muse Code
|
|
156
|
+
|
|
157
|
+
Sign in with Muse Code (`/login`). The tool reads Muse's Meta login from `~/.config/muse/auth.json` (under `$XDG_CONFIG_HOME` when set) and never changes it. Muse reports its usage only when it signs in and at the end of each model call, so the tool makes the sign-in's key request, `POST https://api.meta.ai/muse-code/key`, which uses no quota and, as checked with Muse 1.4.12, returns the key Muse already stored. The windows are the 5-hour window and the weekly limit, and the plan is the subscription tier, such as Muse Code Power Usage. The account id is a fingerprint of the account's email.
|
|
158
|
+
|
|
153
159
|
## OpenCode Go
|
|
154
160
|
|
|
155
161
|
Connect OpenCode Go in OpenCode with `/connect`. The tool reads the Go key OpenCode stores in `~/.local/share/opencode/auth.json` (under `$XDG_DATA_HOME` when set) and never changes the file. The account id is a fingerprint of the key.
|
|
@@ -52,6 +52,11 @@ WRAPPERS = {
|
|
|
52
52
|
"description": "Gemini CLI model quota monitor",
|
|
53
53
|
"keywords": ["gemini", "gemini-cli", "google", "usage", "quota", "statusline", "cli"],
|
|
54
54
|
},
|
|
55
|
+
"muse-code-usage": {
|
|
56
|
+
"provider": "muse-code", "label": "Muse Code",
|
|
57
|
+
"description": "Muse Code subscription limit monitor",
|
|
58
|
+
"keywords": ["muse", "muse-code", "meta", "usage", "quota", "statusline", "cli"],
|
|
59
|
+
},
|
|
55
60
|
"opencode-cli-usage": {
|
|
56
61
|
"provider": "opencode", "label": "OpenCode Go",
|
|
57
62
|
"description": "OpenCode Go subscription limit monitor",
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clanker-usage"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.4"
|
|
8
8
|
description = "Plan and quota of coding-agent accounts, with a statusline, a cache daemon and a check for provider changes"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = ["usage", "quota", "statusline", "z.ai", "ai-agents", "cli"]
|
|
@@ -146,10 +146,19 @@ def render(provider, record):
|
|
|
146
146
|
reset = datetime.fromisoformat(window["resets_at"]).astimezone()
|
|
147
147
|
line += f", resets {reset:%Y-%m-%d %H:%M %Z}"
|
|
148
148
|
lines.append(line)
|
|
149
|
+
lines += [f" {note}" for note in provider_notes(provider, record)]
|
|
149
150
|
note = status_note(record)
|
|
150
151
|
return "\n".join(lines + ([f" ({note})"] if note else []))
|
|
151
152
|
|
|
152
153
|
|
|
154
|
+
def provider_notes(provider, record):
|
|
155
|
+
"""What a provider adds about the document beyond its windows, such as Devin's free-model rate limits."""
|
|
156
|
+
try:
|
|
157
|
+
return provider.notes(record) if hasattr(provider, "notes") else []
|
|
158
|
+
except (KeyError, TypeError, ValueError):
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
|
|
153
162
|
def harness_prefix(status):
|
|
154
163
|
"""The directory, model and cost from the status JSON a harness pipes to its statusline, as Claude Code does."""
|
|
155
164
|
if not isinstance(status, dict):
|
|
@@ -191,6 +200,7 @@ def statusline(provider, record):
|
|
|
191
200
|
if until:
|
|
192
201
|
part += f" ({until})"
|
|
193
202
|
parts.append(part)
|
|
203
|
+
parts += provider_notes(provider, record)
|
|
194
204
|
return " · ".join(parts) + (" (stale)" if record["status"] == "stale" else "")
|
|
195
205
|
|
|
196
206
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Where clanker-usage keeps its cache, check state and configuration."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from . import cache
|
|
@@ -36,3 +37,18 @@ def set_config(provider, section):
|
|
|
36
37
|
data = cache.read_json(config_file()) or {}
|
|
37
38
|
data[provider] = section
|
|
38
39
|
cache.write_json(config_file(), data)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def executable(name):
|
|
43
|
+
"""A harness's command: on PATH, else where user installers put it.
|
|
44
|
+
|
|
45
|
+
A systemd user service runs with a PATH that leaves out ~/.local/bin, where Claude Code, Devin,
|
|
46
|
+
agy and Gemini CLI install themselves, so PATH alone misses them under a daemon.
|
|
47
|
+
"""
|
|
48
|
+
if found := shutil.which(name):
|
|
49
|
+
return Path(found)
|
|
50
|
+
for folder in (Path.home() / ".local/bin", Path.home() / "bin", Path("/usr/local/bin"), Path("/usr/bin")):
|
|
51
|
+
candidate = folder / name
|
|
52
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
53
|
+
return candidate
|
|
54
|
+
return None
|
|
@@ -8,6 +8,6 @@ add_commands(sub) and run_command(args) for its own setup command, and selectors
|
|
|
8
8
|
when one person can have several accounts on one machine, such as Claude's config directories.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from . import agy, claude, codex, cursor, deepseek, devin, gemini, opencode, zcode
|
|
11
|
+
from . import agy, claude, codex, cursor, deepseek, devin, gemini, muse, opencode, zcode
|
|
12
12
|
|
|
13
|
-
PROVIDERS = {module.NAME: module for module in (agy, claude, codex, cursor, deepseek, devin, gemini, opencode, zcode)}
|
|
13
|
+
PROVIDERS = {module.NAME: module for module in (agy, claude, codex, cursor, deepseek, devin, gemini, muse, opencode, zcode)}
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
-
import shutil
|
|
6
5
|
import subprocess
|
|
7
6
|
from datetime import datetime, timedelta, timezone
|
|
8
7
|
from functools import cache
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
|
|
11
|
-
from .. import cache as files, http, oauth
|
|
10
|
+
from .. import cache as files, http, oauth, paths
|
|
12
11
|
from . import google
|
|
13
12
|
|
|
14
13
|
NAME = "agy"
|
|
@@ -29,13 +28,7 @@ def token_file():
|
|
|
29
28
|
|
|
30
29
|
|
|
31
30
|
def agy_binary():
|
|
32
|
-
|
|
33
|
-
if found:
|
|
34
|
-
return Path(found)
|
|
35
|
-
for candidate in (Path.home() / ".local/bin/agy", Path.home() / "bin/agy", Path("/usr/local/bin/agy"), Path("/usr/bin/agy")):
|
|
36
|
-
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
37
|
-
return candidate
|
|
38
|
-
return None
|
|
31
|
+
return paths.executable("agy")
|
|
39
32
|
|
|
40
33
|
|
|
41
34
|
@cache
|
|
@@ -6,7 +6,6 @@ import json
|
|
|
6
6
|
import math
|
|
7
7
|
import os
|
|
8
8
|
import queue
|
|
9
|
-
import shutil
|
|
10
9
|
import subprocess
|
|
11
10
|
import threading
|
|
12
11
|
import time
|
|
@@ -14,7 +13,7 @@ from datetime import datetime, timezone
|
|
|
14
13
|
from importlib.metadata import PackageNotFoundError, version
|
|
15
14
|
from pathlib import Path
|
|
16
15
|
|
|
17
|
-
from .. import cache as files, http
|
|
16
|
+
from .. import cache as files, http, paths
|
|
18
17
|
|
|
19
18
|
NAME = "codex"
|
|
20
19
|
LABEL = "Codex"
|
|
@@ -93,7 +92,7 @@ def _response(messages, request_id, deadline):
|
|
|
93
92
|
|
|
94
93
|
def via_app_server():
|
|
95
94
|
"""account/rateLimits/read from `codex app-server`, or None when Codex is missing or does not answer."""
|
|
96
|
-
codex =
|
|
95
|
+
codex = paths.executable("codex")
|
|
97
96
|
if not codex:
|
|
98
97
|
return None
|
|
99
98
|
deadline = time.monotonic() + APP_SERVER_TIMEOUT
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
"""Devin quota and credits, read with the local API key the Devin CLI already holds."""
|
|
2
2
|
|
|
3
|
+
import glob
|
|
3
4
|
import hashlib
|
|
4
5
|
import math
|
|
5
6
|
import os
|
|
6
7
|
import re
|
|
7
|
-
import
|
|
8
|
+
import time
|
|
8
9
|
import subprocess
|
|
9
10
|
import tomllib
|
|
10
11
|
import urllib.parse
|
|
11
|
-
from datetime import datetime, timezone
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
14
|
-
from .. import http
|
|
15
|
+
from .. import http, paths
|
|
15
16
|
|
|
16
17
|
NAME = "devin"
|
|
17
18
|
LABEL = "Devin"
|
|
@@ -26,6 +27,73 @@ CREDIT_KEYS = ("availablePromptCredits", "usedPromptCredits", "availableFlowCred
|
|
|
26
27
|
"availableFlexCredits", "usedFlexCredits", "overageBalanceMicros", "acuConsumed", "acuLimit")
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
# Devin's free models have a rate limit that its status API does not report; the only record is the
|
|
31
|
+
# error Devin writes to its CLI log, such as "Reached free model rate limit. ... Your limit will reset
|
|
32
|
+
# in 1 minute (at 09:51 UTC)." It names no model, so a hit's model comes from the log's model line.
|
|
33
|
+
RATE_LIMIT = re.compile(r"^(\S+)\s.*Reached free model rate limit\..*?reset in [^(]*\(at (\d{1,2}):(\d{2}) UTC\)")
|
|
34
|
+
MODEL_LINE = re.compile(r"resolved_model_uid=([A-Za-z0-9_.-]+)|model_uid=([A-Za-z0-9_.-]+)")
|
|
35
|
+
# Logs older than this are not read, so status stays fast on hosts with many Devin homes.
|
|
36
|
+
LOG_MAX_AGE = 7 * 86400
|
|
37
|
+
HISTORY = 50
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def data_dir():
|
|
41
|
+
return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "devin"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def log_dirs():
|
|
45
|
+
"""Devin's own log directory and every directory matching a glob added with configure --log-glob."""
|
|
46
|
+
found = [data_dir() / "cli" / "logs"]
|
|
47
|
+
for pattern in paths.config(NAME).get("log_globs", []):
|
|
48
|
+
found += [Path(match) for match in sorted(glob.glob(os.path.expanduser(pattern)))]
|
|
49
|
+
return list(dict.fromkeys(folder for folder in found if folder.is_dir()))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def rate_limit_hits(now=None):
|
|
53
|
+
"""Each free-model rate-limit hit in recent Devin logs: when, the reset it named, and the model when the log gives it.
|
|
54
|
+
|
|
55
|
+
Devin retries a failed request and logs the same error several times, so lines in one log that name
|
|
56
|
+
the same reset count as one hit.
|
|
57
|
+
"""
|
|
58
|
+
cutoff = (now or time.time()) - LOG_MAX_AGE
|
|
59
|
+
hits = {}
|
|
60
|
+
for folder in log_dirs():
|
|
61
|
+
for log in folder.glob("*.log"):
|
|
62
|
+
try:
|
|
63
|
+
if log.stat().st_mtime < cutoff:
|
|
64
|
+
continue
|
|
65
|
+
lines = log.read_text(errors="replace").splitlines()
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
model = None
|
|
69
|
+
for line in lines:
|
|
70
|
+
if (found := MODEL_LINE.search(line)) and "Reached free model rate limit" not in line:
|
|
71
|
+
model = found.group(1) or found.group(2)
|
|
72
|
+
if "Reached free model rate limit" not in line or not (match := RATE_LIMIT.match(line)):
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
at = datetime.fromisoformat(match.group(1).replace("Z", "+00:00")).astimezone(timezone.utc)
|
|
76
|
+
except ValueError:
|
|
77
|
+
continue
|
|
78
|
+
reset = at.replace(hour=int(match.group(2)), minute=int(match.group(3)), second=0, microsecond=0)
|
|
79
|
+
if reset < at - timedelta(minutes=1):
|
|
80
|
+
reset += timedelta(days=1) # The reset is after midnight UTC.
|
|
81
|
+
key = (str(log), reset)
|
|
82
|
+
if key not in hits:
|
|
83
|
+
hits[key] = {"at": at.isoformat(timespec="seconds"), "resets_at": reset.isoformat(), "model": model}
|
|
84
|
+
return sorted(hits.values(), key=lambda hit: hit["at"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def free_model_limits(hits, now):
|
|
88
|
+
"""Per model, the last hit, its reset and whether the limit still applies at now; a model of None is unnamed."""
|
|
89
|
+
latest = {}
|
|
90
|
+
for hit in hits:
|
|
91
|
+
latest[hit["model"]] = hit
|
|
92
|
+
return [{"model": model, "last_hit_at": hit["at"], "resets_at": hit["resets_at"],
|
|
93
|
+
"limited": datetime.fromisoformat(hit["resets_at"]) > now}
|
|
94
|
+
for model, hit in sorted(latest.items(), key=lambda item: item[1]["at"], reverse=True)]
|
|
95
|
+
|
|
96
|
+
|
|
29
97
|
def credentials_file():
|
|
30
98
|
return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "devin" / "credentials.toml"
|
|
31
99
|
|
|
@@ -53,16 +121,21 @@ def account():
|
|
|
53
121
|
return hashlib.sha256(credentials()[0].encode()).hexdigest()[:16]
|
|
54
122
|
|
|
55
123
|
|
|
124
|
+
# Devin's server answers HTTP 500 when the client version is not a version, so a host without the
|
|
125
|
+
# devin command sends this recent one.
|
|
126
|
+
FALLBACK_VERSION = "3000.11.205"
|
|
127
|
+
|
|
128
|
+
|
|
56
129
|
def devin_version():
|
|
57
|
-
executable =
|
|
130
|
+
executable = paths.executable("devin")
|
|
58
131
|
if not executable:
|
|
59
|
-
return
|
|
132
|
+
return FALLBACK_VERSION
|
|
60
133
|
try:
|
|
61
134
|
result = subprocess.run([executable, "version"], capture_output=True, text=True, timeout=5, check=False)
|
|
62
135
|
except (OSError, subprocess.SubprocessError):
|
|
63
|
-
return
|
|
136
|
+
return FALLBACK_VERSION
|
|
64
137
|
match = re.search(r"\b(\d+(?:\.\d+)+)\b", result.stdout)
|
|
65
|
-
return match.group(1) if match else
|
|
138
|
+
return match.group(1) if match else FALLBACK_VERSION
|
|
66
139
|
|
|
67
140
|
|
|
68
141
|
def fetch():
|
|
@@ -79,6 +152,7 @@ def fetch():
|
|
|
79
152
|
return {
|
|
80
153
|
"planInfo": {key: info[key] for key in PLAN_KEYS if key in info} if isinstance(info, dict) else {},
|
|
81
154
|
"planStatus": {key: value for key, value in plan_status.items() if key != "planInfo"},
|
|
155
|
+
"rateLimitHits": rate_limit_hits()[-HISTORY:],
|
|
82
156
|
}
|
|
83
157
|
|
|
84
158
|
|
|
@@ -116,4 +190,36 @@ def normalize(payload):
|
|
|
116
190
|
plan = info.get("planName")
|
|
117
191
|
details = {"credits": credits, "plan_period": {"start": status.get("planStart"), "end": status.get("planEnd")}}
|
|
118
192
|
details.update({key: info[key] for key in PLAN_KEYS[1:] if key in info})
|
|
193
|
+
hits = payload.get("rateLimitHits") or []
|
|
194
|
+
if not isinstance(hits, list) or not all(isinstance(hit, dict) and isinstance(hit.get("resets_at"), str) for hit in hits):
|
|
195
|
+
raise ValueError("Devin rate-limit hits are malformed")
|
|
196
|
+
details["free_model_limits"] = free_model_limits(hits, datetime.now(timezone.utc))
|
|
197
|
+
details["free_model_limit_hits"] = hits
|
|
119
198
|
return {"plan": plan if isinstance(plan, str) and plan else None, "windows": windows, "details": details}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def notes(record):
|
|
202
|
+
"""Free models whose rate limit applies now, for status and statusline."""
|
|
203
|
+
now = datetime.now(timezone.utc)
|
|
204
|
+
out = []
|
|
205
|
+
for limit in (record.get("details") or {}).get("free_model_limits") or []:
|
|
206
|
+
reset = datetime.fromisoformat(limit["resets_at"])
|
|
207
|
+
if reset > now:
|
|
208
|
+
out.append(f"{limit['model'] or 'free model'} rate-limited until {reset.astimezone():%H:%M}")
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def add_commands(sub):
|
|
213
|
+
configure = sub.add_parser("configure", help="Also read Devin logs from directories matching a glob")
|
|
214
|
+
configure.add_argument("--log-glob", required=True,
|
|
215
|
+
help="such as the logs of Devin homes another tool creates, ~/work/*/devin/cli/logs")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_command(args):
|
|
219
|
+
if args.command != "configure":
|
|
220
|
+
return False
|
|
221
|
+
section = paths.config(NAME)
|
|
222
|
+
section["log_globs"] = list(dict.fromkeys([*section.get("log_globs", []), args.log_glob]))
|
|
223
|
+
paths.set_config(NAME, section)
|
|
224
|
+
print(f"Configured {paths.config_file()}; {len(log_dirs())} Devin log directories match")
|
|
225
|
+
return True
|
|
@@ -8,12 +8,11 @@ key has no quota to read.
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
-
import shutil
|
|
12
11
|
import sys
|
|
13
12
|
import time
|
|
14
13
|
from pathlib import Path
|
|
15
14
|
|
|
16
|
-
from .. import cache as files, http, oauth
|
|
15
|
+
from .. import cache as files, http, oauth, paths
|
|
17
16
|
from ..errors import ExpectedUnavailable
|
|
18
17
|
from . import google
|
|
19
18
|
|
|
@@ -84,7 +83,7 @@ def _oauth2_sources():
|
|
|
84
83
|
relative = [Path("node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"),
|
|
85
84
|
Path("node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js")]
|
|
86
85
|
roots = []
|
|
87
|
-
if binary :=
|
|
86
|
+
if binary := paths.executable("gemini"):
|
|
88
87
|
resolved = Path(binary).resolve()
|
|
89
88
|
if resolved.parent.name == "bundle":
|
|
90
89
|
yield from sorted(resolved.parent.glob("*.js"))
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Muse Code subscription limits, read with the login Muse Code already holds.
|
|
2
|
+
|
|
3
|
+
Muse reports its usage in only two places: the key call it makes at /login, and an event at the end
|
|
4
|
+
of every model call. This provider makes the key call, which uses no quota. Checked on 2026-10-25
|
|
5
|
+
with Muse 1.4.12: the call returns the API key already stored, and leaves Muse's login working.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .. import cache as files, http
|
|
15
|
+
|
|
16
|
+
NAME = "muse-code"
|
|
17
|
+
LABEL = "Muse Code"
|
|
18
|
+
PACKAGE = "muse-code-usage"
|
|
19
|
+
DESCRIPTION = "Read Muse Code subscription limits with the login Muse Code already holds."
|
|
20
|
+
KEY_URL = "https://api.meta.ai/muse-code/key"
|
|
21
|
+
INTERVAL = 300
|
|
22
|
+
KEEP = frozenset({"is_subs_active", "is_subs_upgrade_available", "subs_tier_name", "window_duration_mins"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def auth_file():
|
|
26
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "muse" / "auth.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def login():
|
|
30
|
+
"""Muse's Meta login, read without changing Muse's file."""
|
|
31
|
+
meta = ((files.read_json(auth_file()) or {}).get("providers") or {}).get("meta")
|
|
32
|
+
if not isinstance(meta, dict) or not meta.get("access_token") or not meta.get("api_key"):
|
|
33
|
+
raise RuntimeError(f"Muse Code is not signed in: no Meta login in {auth_file()}; run muse and /login")
|
|
34
|
+
return meta
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def account():
|
|
38
|
+
"""A one-way fingerprint of the account's email, else of its API key."""
|
|
39
|
+
meta = login()
|
|
40
|
+
email = meta.get("user_email")
|
|
41
|
+
source = "email:" + email.strip().lower() if isinstance(email, str) and email.strip() else meta["api_key"]
|
|
42
|
+
return hashlib.sha256(source.encode()).hexdigest()[:16]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fetch():
|
|
46
|
+
"""The subscription part of the key response; the key itself, names and payment details are left out."""
|
|
47
|
+
response = http.request_json(KEY_URL, {"Authorization": "Bearer " + login()["access_token"], "x-api-version": "1.0.0"},
|
|
48
|
+
{"onboard": False})
|
|
49
|
+
if not isinstance(response, dict):
|
|
50
|
+
raise ValueError("Muse Code key response is not an object")
|
|
51
|
+
return {key: response[key] for key in ("is_subs_active", "subs_tier_name", "is_subs_upgrade_available", "subs_usage")
|
|
52
|
+
if key in response}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def percent(value):
|
|
56
|
+
if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value) or not 0 <= value <= 100:
|
|
57
|
+
raise ValueError("Invalid Muse Code usage: used_percent must be between 0 and 100")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def reset(value):
|
|
62
|
+
if value is None:
|
|
63
|
+
return None
|
|
64
|
+
if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value) or value < 0:
|
|
65
|
+
raise ValueError("Invalid Muse Code usage: resets_at must be a Unix time")
|
|
66
|
+
return datetime.fromtimestamp(value, timezone.utc).isoformat()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize(payload):
|
|
70
|
+
"""The short window and the weekly limit; an account without a subscription has no plan."""
|
|
71
|
+
if not payload.get("is_subs_active"):
|
|
72
|
+
return {"plan": None, "windows": [], "details": {}}
|
|
73
|
+
usage = payload.get("subs_usage")
|
|
74
|
+
if not isinstance(usage, dict):
|
|
75
|
+
raise ValueError("Muse Code key response has no subscription usage")
|
|
76
|
+
windows = []
|
|
77
|
+
window = usage.get("window")
|
|
78
|
+
if isinstance(window, dict):
|
|
79
|
+
minutes = window.get("window_duration_mins")
|
|
80
|
+
if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes <= 0:
|
|
81
|
+
raise ValueError("Invalid Muse Code usage: window_duration_mins must be a positive whole number")
|
|
82
|
+
name = f"{minutes // 60}-hour" if minutes % 60 == 0 else f"{minutes}-minute"
|
|
83
|
+
windows.append({"name": name, "seconds": minutes * 60, "scope": None,
|
|
84
|
+
"used_pct": percent(window.get("used_percent")), "resets_at": reset(window.get("resets_at"))})
|
|
85
|
+
weekly = usage.get("weekly")
|
|
86
|
+
if isinstance(weekly, dict):
|
|
87
|
+
windows.append({"name": "weekly", "seconds": 7 * 86400, "scope": None,
|
|
88
|
+
"used_pct": percent(weekly.get("used_percent")), "resets_at": reset(weekly.get("resets_at"))})
|
|
89
|
+
if not windows:
|
|
90
|
+
raise ValueError("Muse Code key response has no usage windows")
|
|
91
|
+
plan = payload.get("subs_tier_name")
|
|
92
|
+
return {"plan": plan if isinstance(plan, str) and plan else None, "windows": windows,
|
|
93
|
+
"details": {"upgrade_available": bool(payload.get("is_subs_upgrade_available"))}}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"planInfo": {
|
|
3
|
+
"planName": "synthetic",
|
|
4
|
+
"billingStrategy": "BILLING_STRATEGY_QUOTA",
|
|
5
|
+
"monthlyPromptCredits": 1
|
|
6
|
+
},
|
|
7
|
+
"planStatus": {
|
|
8
|
+
"planStart": "2001-01-01T00:00:00Z",
|
|
9
|
+
"planEnd": "2001-01-01T00:00:00Z",
|
|
10
|
+
"availablePromptCredits": 1,
|
|
11
|
+
"dailyQuotaRemainingPercent": 1,
|
|
12
|
+
"weeklyQuotaRemainingPercent": 1,
|
|
13
|
+
"overageBalanceMicros": "-100000",
|
|
14
|
+
"dailyQuotaResetAtUnix": "1000000000",
|
|
15
|
+
"weeklyQuotaResetAtUnix": "1000000000"
|
|
16
|
+
},
|
|
17
|
+
"rateLimitHits": [
|
|
18
|
+
{
|
|
19
|
+
"at": "2001-01-01T00:00:00+00:00",
|
|
20
|
+
"resets_at": "2001-01-01T00:00:00+00:00",
|
|
21
|
+
"model": null
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"at": "2001-01-01T00:00:00+00:00",
|
|
25
|
+
"resets_at": "2001-01-01T00:00:00+00:00",
|
|
26
|
+
"model": "synthetic"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"is_subs_active": true,
|
|
3
|
+
"subs_tier_name": "Muse Code Power Usage",
|
|
4
|
+
"is_subs_upgrade_available": true,
|
|
5
|
+
"subs_usage": {
|
|
6
|
+
"window": {
|
|
7
|
+
"used_percent": 1,
|
|
8
|
+
"window_duration_mins": 300,
|
|
9
|
+
"resets_at": 1
|
|
10
|
+
},
|
|
11
|
+
"weekly": {
|
|
12
|
+
"used_percent": 1,
|
|
13
|
+
"resets_at": 1
|
|
14
|
+
},
|
|
15
|
+
"tier": "10000000000000000"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"product": "go",
|
|
3
|
+
"renewalProduct": "go",
|
|
4
|
+
"resumability": "renewing",
|
|
5
|
+
"cancelAtPeriodEnd": false,
|
|
6
|
+
"access": {
|
|
7
|
+
"startsAt": "2001-01-01T00:00:00Z",
|
|
8
|
+
"endsAt": "2001-01-01T00:00:00Z",
|
|
9
|
+
"cancelAtPeriodEnd": false,
|
|
10
|
+
"meters": {
|
|
11
|
+
"fiveHour": {
|
|
12
|
+
"startsAt": null,
|
|
13
|
+
"resetsAt": null,
|
|
14
|
+
"limitMicroCents": "1000000000",
|
|
15
|
+
"usedMicroCents": "1"
|
|
16
|
+
},
|
|
17
|
+
"week": {
|
|
18
|
+
"startsAt": "2001-01-01T00:00:00Z",
|
|
19
|
+
"resetsAt": "2001-01-01T00:00:00Z",
|
|
20
|
+
"limitMicroCents": "1000000000",
|
|
21
|
+
"usedMicroCents": "100000000"
|
|
22
|
+
},
|
|
23
|
+
"month": {
|
|
24
|
+
"resetsAt": "2001-01-01T00:00:00Z",
|
|
25
|
+
"limitMicroCents": "1000000000",
|
|
26
|
+
"usedMicroCents": "100000000"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -170,7 +170,7 @@ class StandardCommandTests(Home):
|
|
|
170
170
|
self.assertEqual(cli.main(["--json"]), 0)
|
|
171
171
|
statuses = {record["provider"]: record["status"] for record in json.loads(out.getvalue())}
|
|
172
172
|
self.assertEqual(statuses, {"agy": "unavailable", "claude": "unavailable", "codex": "unavailable", "cursor": "unavailable", "deepseek": "unavailable",
|
|
173
|
-
"devin": "unavailable", "gemini": "unavailable", "opencode": "unavailable",
|
|
173
|
+
"devin": "unavailable", "gemini": "unavailable", "muse-code": "unavailable", "opencode": "unavailable",
|
|
174
174
|
"zcode": "cached"})
|
|
175
175
|
|
|
176
176
|
def test_refresh_fails_without_a_key(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/agy/agy-2026-10-19.json
RENAMED
|
File without changes
|
{clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/claude/claude-2026-10-19.json
RENAMED
|
File without changes
|
{clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/codex/api-2026-10-19.json
RENAMED
|
File without changes
|
|
File without changes
|
{clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/cursor/cursor-2026-10-19.json
RENAMED
|
File without changes
|
|
File without changes
|
{clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/devin/devin-2026-10-19.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/zcode/no-plan-2026-10-18.json
RENAMED
|
File without changes
|
{clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/zcode/quota-2026-09-05.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|