devin-cli-usage 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devin_cli_usage-0.1.0/.github/workflows/publish.yml +29 -0
- devin_cli_usage-0.1.0/.gitignore +6 -0
- devin_cli_usage-0.1.0/PKG-INFO +108 -0
- devin_cli_usage-0.1.0/README.md +99 -0
- devin_cli_usage-0.1.0/dist/.gitignore +2 -0
- devin_cli_usage-0.1.0/pyproject.toml +24 -0
- devin_cli_usage-0.1.0/src/devin_cli_usage/__init__.py +414 -0
- devin_cli_usage-0.1.0/src/devin_cli_usage/__main__.py +3 -0
- devin_cli_usage-0.1.0/tests/test_devin_cli_usage.py +143 -0
- devin_cli_usage-0.1.0/uv.lock +8 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
id-token: write
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout
|
|
17
|
+
uses: actions/checkout@v7.0.1
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v9.0.0
|
|
20
|
+
with:
|
|
21
|
+
enable-cache: false
|
|
22
|
+
- name: Verify version tag
|
|
23
|
+
run: test "$GITHUB_REF_NAME" = "v$(uv --no-config version --short)"
|
|
24
|
+
- name: Test
|
|
25
|
+
run: uv --no-config run --locked python -m unittest discover -s tests
|
|
26
|
+
- name: Build
|
|
27
|
+
run: uv --no-config build --no-sources
|
|
28
|
+
- name: Publish
|
|
29
|
+
run: uv publish --trusted-publishing always
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devin-cli-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Devin CLI account quota monitor using local Devin authentication
|
|
5
|
+
Project-URL: Homepage, https://github.com/wakamex/devin-cli-usage
|
|
6
|
+
Project-URL: Source, https://github.com/wakamex/devin-cli-usage
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# devin-cli-usage
|
|
11
|
+
|
|
12
|
+
Devin account usage and quota monitor. It follows the same dependency-free
|
|
13
|
+
interface as the other `/code/*usage` tools.
|
|
14
|
+
|
|
15
|
+
The tool targets the local Devin CLI harness, but reports account-wide Devin
|
|
16
|
+
quota. It does not estimate usage from local CLI sessions.
|
|
17
|
+
|
|
18
|
+
## Example output
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
Devin usage
|
|
22
|
+
Status: live
|
|
23
|
+
Plan: Pro
|
|
24
|
+
Daily: 100% remaining, resets in 11h
|
|
25
|
+
Weekly: 100% remaining, resets in 6d11h
|
|
26
|
+
Prompt credits available: -1
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Statusline:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
devin:daily:100%left reset:11h
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv tool install devin-cli-usage
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For local development:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv tool install .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
| Command | Description |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `devin-cli-usage` | Show current account usage |
|
|
52
|
+
| `devin-cli-usage status` | Same as above |
|
|
53
|
+
| `devin-cli-usage json` | Print normalized JSON |
|
|
54
|
+
| `devin-cli-usage statusline` | Print compact cached output |
|
|
55
|
+
| `devin-cli-usage refresh` | Refresh the cache and print status |
|
|
56
|
+
| `devin-cli-usage daemon [-i SECS]` | Keep the cache fresh |
|
|
57
|
+
| `devin-cli-usage install` | Print installation instructions |
|
|
58
|
+
|
|
59
|
+
## Authentication and data
|
|
60
|
+
|
|
61
|
+
The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
|
|
62
|
+
`~/.local/share/devin/credentials.toml`, then calls the read-only
|
|
63
|
+
`GetUserStatus` service used by the installed client. It rereads credentials
|
|
64
|
+
for every live request. It never refreshes credentials or modifies
|
|
65
|
+
Devin-owned files.
|
|
66
|
+
|
|
67
|
+
The normalized JSON includes:
|
|
68
|
+
|
|
69
|
+
- `provider`, `source`, `retrieved_at`, and `status`
|
|
70
|
+
- plan name, billing strategy, and plan period
|
|
71
|
+
- daily and weekly quota percentages and reset times
|
|
72
|
+
- available and consumed credits, ACU limits, and overage balance when supplied
|
|
73
|
+
|
|
74
|
+
`status` is `live`, `cached`, `stale`, or `unavailable`. Errors and caches do
|
|
75
|
+
not include the API key or Devin account identity. A credit value of `-1` is a
|
|
76
|
+
vendor sentinel for an unlimited or non-credit-based allowance and is
|
|
77
|
+
preserved verbatim.
|
|
78
|
+
|
|
79
|
+
The tool writes only its own non-secret cache at
|
|
80
|
+
`~/.local/share/devin/usage-limits.json`. JSON results report whether they are
|
|
81
|
+
`live`, `cached`, `stale`, or `unavailable`.
|
|
82
|
+
|
|
83
|
+
Environment overrides:
|
|
84
|
+
|
|
85
|
+
- `DEVIN_USAGE_CREDENTIALS_FILE`: alternate credentials file
|
|
86
|
+
- `DEVIN_USAGE_FILE`: alternate cache path
|
|
87
|
+
|
|
88
|
+
The user-status service is undocumented and may change with Devin client
|
|
89
|
+
releases.
|
|
90
|
+
|
|
91
|
+
## Options
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
usage: devin-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
|
|
95
|
+
{status,json,daemon,statusline,refresh,install}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- `--max-age SECS`: maximum cache age used by `statusline`
|
|
99
|
+
- `--refresh`: bypass the cache for `statusline`
|
|
100
|
+
- `-i SECS`: daemon refresh interval
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
uv --no-config lock --check
|
|
106
|
+
uv --no-config run --locked python -m unittest discover -s tests
|
|
107
|
+
uv --no-config build --no-sources
|
|
108
|
+
```
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# devin-cli-usage
|
|
2
|
+
|
|
3
|
+
Devin account usage and quota monitor. It follows the same dependency-free
|
|
4
|
+
interface as the other `/code/*usage` tools.
|
|
5
|
+
|
|
6
|
+
The tool targets the local Devin CLI harness, but reports account-wide Devin
|
|
7
|
+
quota. It does not estimate usage from local CLI sessions.
|
|
8
|
+
|
|
9
|
+
## Example output
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
Devin usage
|
|
13
|
+
Status: live
|
|
14
|
+
Plan: Pro
|
|
15
|
+
Daily: 100% remaining, resets in 11h
|
|
16
|
+
Weekly: 100% remaining, resets in 6d11h
|
|
17
|
+
Prompt credits available: -1
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Statusline:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
devin:daily:100%left reset:11h
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv tool install devin-cli-usage
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For local development:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv tool install .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
| Command | Description |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `devin-cli-usage` | Show current account usage |
|
|
43
|
+
| `devin-cli-usage status` | Same as above |
|
|
44
|
+
| `devin-cli-usage json` | Print normalized JSON |
|
|
45
|
+
| `devin-cli-usage statusline` | Print compact cached output |
|
|
46
|
+
| `devin-cli-usage refresh` | Refresh the cache and print status |
|
|
47
|
+
| `devin-cli-usage daemon [-i SECS]` | Keep the cache fresh |
|
|
48
|
+
| `devin-cli-usage install` | Print installation instructions |
|
|
49
|
+
|
|
50
|
+
## Authentication and data
|
|
51
|
+
|
|
52
|
+
The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
|
|
53
|
+
`~/.local/share/devin/credentials.toml`, then calls the read-only
|
|
54
|
+
`GetUserStatus` service used by the installed client. It rereads credentials
|
|
55
|
+
for every live request. It never refreshes credentials or modifies
|
|
56
|
+
Devin-owned files.
|
|
57
|
+
|
|
58
|
+
The normalized JSON includes:
|
|
59
|
+
|
|
60
|
+
- `provider`, `source`, `retrieved_at`, and `status`
|
|
61
|
+
- plan name, billing strategy, and plan period
|
|
62
|
+
- daily and weekly quota percentages and reset times
|
|
63
|
+
- available and consumed credits, ACU limits, and overage balance when supplied
|
|
64
|
+
|
|
65
|
+
`status` is `live`, `cached`, `stale`, or `unavailable`. Errors and caches do
|
|
66
|
+
not include the API key or Devin account identity. A credit value of `-1` is a
|
|
67
|
+
vendor sentinel for an unlimited or non-credit-based allowance and is
|
|
68
|
+
preserved verbatim.
|
|
69
|
+
|
|
70
|
+
The tool writes only its own non-secret cache at
|
|
71
|
+
`~/.local/share/devin/usage-limits.json`. JSON results report whether they are
|
|
72
|
+
`live`, `cached`, `stale`, or `unavailable`.
|
|
73
|
+
|
|
74
|
+
Environment overrides:
|
|
75
|
+
|
|
76
|
+
- `DEVIN_USAGE_CREDENTIALS_FILE`: alternate credentials file
|
|
77
|
+
- `DEVIN_USAGE_FILE`: alternate cache path
|
|
78
|
+
|
|
79
|
+
The user-status service is undocumented and may change with Devin client
|
|
80
|
+
releases.
|
|
81
|
+
|
|
82
|
+
## Options
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
usage: devin-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
|
|
86
|
+
{status,json,daemon,statusline,refresh,install}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `--max-age SECS`: maximum cache age used by `statusline`
|
|
90
|
+
- `--refresh`: bypass the cache for `statusline`
|
|
91
|
+
- `-i SECS`: daemon refresh interval
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv --no-config lock --check
|
|
97
|
+
uv --no-config run --locked python -m unittest discover -s tests
|
|
98
|
+
uv --no-config build --no-sources
|
|
99
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "devin-cli-usage"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Devin CLI account quota monitor using local Devin authentication"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[tool.uv]
|
|
10
|
+
package = true
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/devin_cli_usage"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/wakamex/devin-cli-usage"
|
|
21
|
+
Source = "https://github.com/wakamex/devin-cli-usage"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
devin-cli-usage = "devin_cli_usage:main"
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Devin account usage monitor using Devin-owned local authentication."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import tomllib
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
DEFAULT_CREDENTIALS_FILE = (
|
|
24
|
+
Path.home() / ".local" / "share" / "devin" / "credentials.toml"
|
|
25
|
+
)
|
|
26
|
+
DEFAULT_USAGE_FILE = Path.home() / ".local" / "share" / "devin" / "usage-limits.json"
|
|
27
|
+
USER_STATUS_PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus"
|
|
28
|
+
CACHE_MAX_AGE = 300
|
|
29
|
+
DAEMON_INTERVAL = 300
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _iso_now() -> str:
|
|
33
|
+
return datetime.now(UTC).isoformat()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_iso(value: object) -> datetime | None:
|
|
37
|
+
if not isinstance(value, str) or not value:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
41
|
+
except ValueError:
|
|
42
|
+
return None
|
|
43
|
+
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _number(value: object) -> int | float | None:
|
|
47
|
+
if isinstance(value, bool):
|
|
48
|
+
return None
|
|
49
|
+
if isinstance(value, int | float):
|
|
50
|
+
return value
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
try:
|
|
53
|
+
parsed = float(value)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
return int(parsed) if parsed.is_integer() else parsed
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _format_reset(value: object) -> str:
|
|
61
|
+
seconds = _number(value)
|
|
62
|
+
if seconds is None:
|
|
63
|
+
return ""
|
|
64
|
+
try:
|
|
65
|
+
reset = datetime.fromtimestamp(float(seconds), UTC)
|
|
66
|
+
except (ValueError, OSError):
|
|
67
|
+
return ""
|
|
68
|
+
minutes = max(0, int((reset - datetime.now(UTC)).total_seconds()) // 60)
|
|
69
|
+
if minutes >= 1440:
|
|
70
|
+
return f"{minutes // 1440}d{(minutes % 1440) // 60}h"
|
|
71
|
+
if minutes >= 60:
|
|
72
|
+
return f"{minutes // 60}h{minutes % 60}m"
|
|
73
|
+
return f"{minutes}m"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_credentials_file() -> Path:
|
|
77
|
+
override = os.environ.get("DEVIN_USAGE_CREDENTIALS_FILE")
|
|
78
|
+
return Path(override).expanduser() if override else DEFAULT_CREDENTIALS_FILE
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_usage_file() -> Path:
|
|
82
|
+
override = os.environ.get("DEVIN_USAGE_FILE")
|
|
83
|
+
return Path(override).expanduser() if override else DEFAULT_USAGE_FILE
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_credentials() -> tuple[str, str]:
|
|
87
|
+
"""Read the current API key and server without changing Devin credentials."""
|
|
88
|
+
path = get_credentials_file()
|
|
89
|
+
try:
|
|
90
|
+
data = tomllib.loads(path.read_text())
|
|
91
|
+
except FileNotFoundError as exc:
|
|
92
|
+
raise RuntimeError(f"Devin credentials not found at {path}") from exc
|
|
93
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
94
|
+
raise RuntimeError(f"Could not read Devin credentials from {path}") from exc
|
|
95
|
+
api_key = data.get("windsurf_api_key")
|
|
96
|
+
server = data.get("api_server_url")
|
|
97
|
+
if not isinstance(api_key, str) or not api_key:
|
|
98
|
+
raise RuntimeError("Devin credentials do not contain a local API key")
|
|
99
|
+
if not isinstance(server, str) or not server:
|
|
100
|
+
raise RuntimeError("Devin credentials do not contain an API server")
|
|
101
|
+
parsed = urllib.parse.urlparse(server)
|
|
102
|
+
if parsed.scheme != "https" or not parsed.hostname:
|
|
103
|
+
raise RuntimeError("Devin credential API server is not a valid HTTPS URL")
|
|
104
|
+
return api_key, server.rstrip("/")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _devin_version() -> str:
|
|
108
|
+
executable = shutil.which("devin")
|
|
109
|
+
if not executable:
|
|
110
|
+
return "unknown"
|
|
111
|
+
try:
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
[executable, "version"],
|
|
114
|
+
check=False,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
timeout=5,
|
|
118
|
+
)
|
|
119
|
+
except (OSError, subprocess.SubprocessError):
|
|
120
|
+
return "unknown"
|
|
121
|
+
match = re.search(r"\b(\d+(?:\.\d+)+)\b", result.stdout)
|
|
122
|
+
return match.group(1) if match else "unknown"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def fetch_usage() -> dict:
|
|
126
|
+
api_key, server = get_credentials()
|
|
127
|
+
version = _devin_version()
|
|
128
|
+
payload = {
|
|
129
|
+
"metadata": {
|
|
130
|
+
"apiKey": api_key,
|
|
131
|
+
"ideName": "devin",
|
|
132
|
+
"ideVersion": version,
|
|
133
|
+
"extensionVersion": version,
|
|
134
|
+
"locale": "en",
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
request = urllib.request.Request(
|
|
138
|
+
server + USER_STATUS_PATH,
|
|
139
|
+
data=json.dumps(payload, separators=(",", ":")).encode(),
|
|
140
|
+
method="POST",
|
|
141
|
+
headers={
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
"Connect-Protocol-Version": "1",
|
|
144
|
+
"User-Agent": "devin-cli-usage/0.1",
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
149
|
+
result = json.loads(response.read())
|
|
150
|
+
except urllib.error.HTTPError as exc:
|
|
151
|
+
if exc.code in (401, 403):
|
|
152
|
+
raise RuntimeError(
|
|
153
|
+
"Devin local API key is no longer accepted; sign in with Devin again"
|
|
154
|
+
) from exc
|
|
155
|
+
raise RuntimeError(f"Devin user-status API returned HTTP {exc.code}") from exc
|
|
156
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
157
|
+
raise RuntimeError("Devin user-status API is unavailable") from exc
|
|
158
|
+
except json.JSONDecodeError as exc:
|
|
159
|
+
raise RuntimeError("Devin user-status API returned invalid JSON") from exc
|
|
160
|
+
if not isinstance(result, dict):
|
|
161
|
+
raise RuntimeError("Devin user-status API returned an unexpected response")
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _normalized_plan_info(payload: dict, plan_status: dict) -> dict:
|
|
166
|
+
info = payload.get("planInfo")
|
|
167
|
+
if not isinstance(info, dict):
|
|
168
|
+
info = plan_status.get("planInfo")
|
|
169
|
+
if not isinstance(info, dict):
|
|
170
|
+
info = {}
|
|
171
|
+
return {
|
|
172
|
+
key: value
|
|
173
|
+
for key, source in (
|
|
174
|
+
("name", "planName"),
|
|
175
|
+
("billing_strategy", "billingStrategy"),
|
|
176
|
+
("monthly_prompt_credits", "monthlyPromptCredits"),
|
|
177
|
+
("monthly_flow_credits", "monthlyFlowCredits"),
|
|
178
|
+
("monthly_flex_credits", "monthlyFlexCreditPurchaseAmount"),
|
|
179
|
+
)
|
|
180
|
+
if (value := info.get(source)) is not None
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _normalize(payload: dict) -> dict:
|
|
185
|
+
user_status = payload.get("userStatus")
|
|
186
|
+
if not isinstance(user_status, dict):
|
|
187
|
+
raise RuntimeError("Devin user-status response did not contain userStatus")
|
|
188
|
+
plan_status = user_status.get("planStatus")
|
|
189
|
+
if not isinstance(plan_status, dict):
|
|
190
|
+
plan_status = {}
|
|
191
|
+
quotas = {
|
|
192
|
+
"daily": {
|
|
193
|
+
"remaining_pct": _number(plan_status.get("dailyQuotaRemainingPercent")),
|
|
194
|
+
"reset_at": _number(plan_status.get("dailyQuotaResetAtUnix")),
|
|
195
|
+
},
|
|
196
|
+
"weekly": {
|
|
197
|
+
"remaining_pct": _number(plan_status.get("weeklyQuotaRemainingPercent")),
|
|
198
|
+
"reset_at": _number(plan_status.get("weeklyQuotaResetAtUnix")),
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
credits = {
|
|
202
|
+
key: value
|
|
203
|
+
for key, source in (
|
|
204
|
+
("available_prompt", "availablePromptCredits"),
|
|
205
|
+
("used_prompt", "usedPromptCredits"),
|
|
206
|
+
("available_flow", "availableFlowCredits"),
|
|
207
|
+
("used_flow", "usedFlowCredits"),
|
|
208
|
+
("available_flex", "availableFlexCredits"),
|
|
209
|
+
("used_flex", "usedFlexCredits"),
|
|
210
|
+
("overage_balance_micros", "overageBalanceMicros"),
|
|
211
|
+
("acu_consumed", "acuConsumed"),
|
|
212
|
+
("acu_limit", "acuLimit"),
|
|
213
|
+
)
|
|
214
|
+
if (value := _number(plan_status.get(source))) is not None
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
"plan": _normalized_plan_info(payload, plan_status),
|
|
218
|
+
"plan_period": {
|
|
219
|
+
"start": plan_status.get("planStart"),
|
|
220
|
+
"end": plan_status.get("planEnd"),
|
|
221
|
+
},
|
|
222
|
+
"quotas": quotas,
|
|
223
|
+
"credits": credits,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def build_usage_json() -> dict:
|
|
228
|
+
updated_at = _iso_now()
|
|
229
|
+
try:
|
|
230
|
+
usage = _normalize(fetch_usage())
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
return {
|
|
233
|
+
"provider": "devin",
|
|
234
|
+
"status": "unavailable",
|
|
235
|
+
"source": "devin_user_status_api",
|
|
236
|
+
"retrieved_at": updated_at,
|
|
237
|
+
"error": str(exc),
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
"provider": "devin",
|
|
241
|
+
"status": "live",
|
|
242
|
+
"source": "devin_user_status_api",
|
|
243
|
+
"retrieved_at": updated_at,
|
|
244
|
+
**usage,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def write_usage_file(data: dict) -> None:
|
|
249
|
+
path = get_usage_file()
|
|
250
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
252
|
+
temporary = Path(temporary_name)
|
|
253
|
+
try:
|
|
254
|
+
with os.fdopen(fd, "w") as handle:
|
|
255
|
+
json.dump(data, handle, indent=2)
|
|
256
|
+
handle.write("\n")
|
|
257
|
+
os.replace(temporary, path)
|
|
258
|
+
except Exception:
|
|
259
|
+
temporary.unlink(missing_ok=True)
|
|
260
|
+
raise
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _read_cache() -> dict | None:
|
|
264
|
+
try:
|
|
265
|
+
data = json.loads(get_usage_file().read_text())
|
|
266
|
+
except (OSError, json.JSONDecodeError):
|
|
267
|
+
return None
|
|
268
|
+
return data if isinstance(data, dict) else None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_cached_usage(max_age: int = CACHE_MAX_AGE, force_refresh: bool = False) -> dict:
|
|
272
|
+
cached = _read_cache()
|
|
273
|
+
if not force_refresh and cached:
|
|
274
|
+
retrieved = _parse_iso(cached.get("retrieved_at"))
|
|
275
|
+
if retrieved and (datetime.now(UTC) - retrieved).total_seconds() < max_age:
|
|
276
|
+
return {**cached, "status": "cached"}
|
|
277
|
+
fresh = build_usage_json()
|
|
278
|
+
if fresh["status"] == "live":
|
|
279
|
+
write_usage_file(fresh)
|
|
280
|
+
return fresh
|
|
281
|
+
if cached:
|
|
282
|
+
return {**cached, "status": "stale", "refresh_error": fresh.get("error")}
|
|
283
|
+
return fresh
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _quota_value(data: dict) -> tuple[str, dict] | None:
|
|
287
|
+
quotas = data.get("quotas")
|
|
288
|
+
if not isinstance(quotas, dict):
|
|
289
|
+
return None
|
|
290
|
+
candidates = [
|
|
291
|
+
(name, quota)
|
|
292
|
+
for name, quota in quotas.items()
|
|
293
|
+
if isinstance(quota, dict) and quota.get("remaining_pct") is not None
|
|
294
|
+
]
|
|
295
|
+
return (
|
|
296
|
+
min(candidates, key=lambda item: float(item[1]["remaining_pct"]))
|
|
297
|
+
if candidates
|
|
298
|
+
else None
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _statusline_text(data: dict) -> str:
|
|
303
|
+
selected = _quota_value(data)
|
|
304
|
+
if not selected:
|
|
305
|
+
return "devin:q:unavailable"
|
|
306
|
+
name, quota = selected
|
|
307
|
+
remaining = float(quota["remaining_pct"])
|
|
308
|
+
parts = [f"devin:{name}:{remaining:g}%left"]
|
|
309
|
+
reset = _format_reset(quota.get("reset_at"))
|
|
310
|
+
if reset:
|
|
311
|
+
parts.append(f"reset:{reset}")
|
|
312
|
+
if data.get("status") in {"cached", "stale"}:
|
|
313
|
+
parts.append(str(data["status"]))
|
|
314
|
+
return " ".join(parts)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _print_status(data: dict) -> None:
|
|
318
|
+
print("Devin usage")
|
|
319
|
+
print(f"Status: {data['status']}")
|
|
320
|
+
plan = data.get("plan")
|
|
321
|
+
if isinstance(plan, dict) and plan.get("name"):
|
|
322
|
+
print(f"Plan: {plan['name']}")
|
|
323
|
+
quotas = data.get("quotas")
|
|
324
|
+
if isinstance(quotas, dict):
|
|
325
|
+
for name in ("daily", "weekly"):
|
|
326
|
+
quota = quotas.get(name)
|
|
327
|
+
if isinstance(quota, dict) and quota.get("remaining_pct") is not None:
|
|
328
|
+
suffix = _format_reset(quota.get("reset_at"))
|
|
329
|
+
reset = f", resets in {suffix}" if suffix else ""
|
|
330
|
+
print(
|
|
331
|
+
f"{name.title()}: {float(quota['remaining_pct']):g}% remaining{reset}"
|
|
332
|
+
)
|
|
333
|
+
credits = data.get("credits")
|
|
334
|
+
if isinstance(credits, dict):
|
|
335
|
+
if credits.get("available_prompt") is not None:
|
|
336
|
+
print(f"Prompt credits available: {credits['available_prompt']}")
|
|
337
|
+
if credits.get("available_flow") is not None:
|
|
338
|
+
print(f"Flow credits available: {credits['available_flow']}")
|
|
339
|
+
if credits.get("available_flex") is not None:
|
|
340
|
+
print(f"Flex credits available: {credits['available_flex']}")
|
|
341
|
+
if data.get("error"):
|
|
342
|
+
print(f"Error: {data['error']}")
|
|
343
|
+
if data.get("refresh_error"):
|
|
344
|
+
print(f"Refresh error: {data['refresh_error']}")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def cmd_status(_args: argparse.Namespace) -> None:
|
|
348
|
+
_print_status(build_usage_json())
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cmd_json(_args: argparse.Namespace) -> None:
|
|
352
|
+
print(json.dumps(build_usage_json(), indent=2))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def cmd_statusline(args: argparse.Namespace) -> None:
|
|
356
|
+
print(_statusline_text(get_cached_usage(args.max_age, args.refresh)))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cmd_refresh(_args: argparse.Namespace) -> None:
|
|
360
|
+
data = build_usage_json()
|
|
361
|
+
if data["status"] == "live":
|
|
362
|
+
write_usage_file(data)
|
|
363
|
+
_print_status(data)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_daemon(args: argparse.Namespace) -> None:
|
|
367
|
+
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
368
|
+
if hasattr(signal, "SIGTERM"):
|
|
369
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
370
|
+
print(f"devin-cli-usage daemon started (refreshing every {args.interval}s)")
|
|
371
|
+
print(f"Writing to {get_usage_file()}")
|
|
372
|
+
while True:
|
|
373
|
+
data = build_usage_json()
|
|
374
|
+
if data["status"] == "live":
|
|
375
|
+
write_usage_file(data)
|
|
376
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
|
|
377
|
+
time.sleep(args.interval)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def cmd_install(_args: argparse.Namespace) -> None:
|
|
381
|
+
print(
|
|
382
|
+
"Install with:\n uv tool install devin-cli-usage\n\nThen run:\n devin-cli-usage"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
387
|
+
parser = argparse.ArgumentParser(description="Devin account usage monitor")
|
|
388
|
+
parser.add_argument(
|
|
389
|
+
"command",
|
|
390
|
+
nargs="?",
|
|
391
|
+
default="status",
|
|
392
|
+
choices=["status", "json", "daemon", "statusline", "refresh", "install"],
|
|
393
|
+
)
|
|
394
|
+
parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL)
|
|
395
|
+
parser.add_argument("--max-age", type=int, default=CACHE_MAX_AGE)
|
|
396
|
+
parser.add_argument("--refresh", action="store_true")
|
|
397
|
+
return parser
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main() -> None:
|
|
401
|
+
args = _build_parser().parse_args()
|
|
402
|
+
commands = {
|
|
403
|
+
"status": cmd_status,
|
|
404
|
+
"json": cmd_json,
|
|
405
|
+
"daemon": cmd_daemon,
|
|
406
|
+
"statusline": cmd_statusline,
|
|
407
|
+
"refresh": cmd_refresh,
|
|
408
|
+
"install": cmd_install,
|
|
409
|
+
}
|
|
410
|
+
commands[args.command](args)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
import urllib.error
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
import devin_cli_usage as devin_usage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeResponse:
|
|
14
|
+
def __init__(self, payload: dict):
|
|
15
|
+
self.payload = payload
|
|
16
|
+
|
|
17
|
+
def __enter__(self):
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __exit__(self, *_args):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def read(self):
|
|
24
|
+
return json.dumps(self.payload).encode()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DevinUsageTests(unittest.TestCase):
|
|
28
|
+
def test_credentials_are_reread_without_writes(self):
|
|
29
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
30
|
+
credentials = Path(temporary) / "credentials.toml"
|
|
31
|
+
credentials.write_text(
|
|
32
|
+
'windsurf_api_key = "first"\napi_server_url = "https://server.codeium.com"\n'
|
|
33
|
+
)
|
|
34
|
+
with mock.patch.object(
|
|
35
|
+
devin_usage, "DEFAULT_CREDENTIALS_FILE", credentials
|
|
36
|
+
):
|
|
37
|
+
self.assertEqual(devin_usage.get_credentials()[0], "first")
|
|
38
|
+
credentials.write_text(
|
|
39
|
+
'windsurf_api_key = "second"\napi_server_url = "https://server.codeium.com"\n'
|
|
40
|
+
)
|
|
41
|
+
self.assertEqual(devin_usage.get_credentials()[0], "second")
|
|
42
|
+
|
|
43
|
+
def test_fetch_puts_api_key_in_request_metadata(self):
|
|
44
|
+
payload = {"userStatus": {"planStatus": {}}}
|
|
45
|
+
|
|
46
|
+
def fake_urlopen(request, timeout):
|
|
47
|
+
body = json.loads(request.data)
|
|
48
|
+
metadata = body["metadata"]
|
|
49
|
+
self.assertEqual(metadata["apiKey"], "local-key")
|
|
50
|
+
self.assertEqual(metadata["ideName"], "devin")
|
|
51
|
+
self.assertEqual(metadata["ideVersion"], "3000.3.27")
|
|
52
|
+
self.assertNotIn("Authorization", request.headers)
|
|
53
|
+
self.assertEqual(timeout, 15)
|
|
54
|
+
return FakeResponse(payload)
|
|
55
|
+
|
|
56
|
+
with (
|
|
57
|
+
mock.patch.object(
|
|
58
|
+
devin_usage,
|
|
59
|
+
"get_credentials",
|
|
60
|
+
return_value=("local-key", "https://server.codeium.com"),
|
|
61
|
+
),
|
|
62
|
+
mock.patch.object(devin_usage, "_devin_version", return_value="3000.3.27"),
|
|
63
|
+
mock.patch.object(
|
|
64
|
+
devin_usage.urllib.request, "urlopen", side_effect=fake_urlopen
|
|
65
|
+
),
|
|
66
|
+
):
|
|
67
|
+
self.assertEqual(devin_usage.fetch_usage(), payload)
|
|
68
|
+
|
|
69
|
+
def test_build_normalizes_quota_and_credit_fields(self):
|
|
70
|
+
payload = {
|
|
71
|
+
"planInfo": {"planName": "Pro", "monthlyPromptCredits": 1000},
|
|
72
|
+
"userStatus": {
|
|
73
|
+
"planStatus": {
|
|
74
|
+
"dailyQuotaRemainingPercent": 75,
|
|
75
|
+
"dailyQuotaResetAtUnix": 2_000_000_000,
|
|
76
|
+
"weeklyQuotaRemainingPercent": "50",
|
|
77
|
+
"availablePromptCredits": "900",
|
|
78
|
+
"usedPromptCredits": 100,
|
|
79
|
+
"overageBalanceMicros": "0",
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
with mock.patch.object(devin_usage, "fetch_usage", return_value=payload):
|
|
84
|
+
data = devin_usage.build_usage_json()
|
|
85
|
+
self.assertEqual(data["status"], "live")
|
|
86
|
+
self.assertEqual(data["plan"]["name"], "Pro")
|
|
87
|
+
self.assertEqual(data["quotas"]["daily"]["remaining_pct"], 75)
|
|
88
|
+
self.assertEqual(data["quotas"]["weekly"]["remaining_pct"], 50)
|
|
89
|
+
self.assertEqual(data["credits"]["available_prompt"], 900)
|
|
90
|
+
|
|
91
|
+
def test_non_https_server_is_rejected(self):
|
|
92
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
93
|
+
credentials = Path(temporary) / "credentials.toml"
|
|
94
|
+
credentials.write_text(
|
|
95
|
+
'windsurf_api_key = "key"\napi_server_url = "http://server.codeium.com"\n'
|
|
96
|
+
)
|
|
97
|
+
with mock.patch.object(
|
|
98
|
+
devin_usage, "DEFAULT_CREDENTIALS_FILE", credentials
|
|
99
|
+
):
|
|
100
|
+
with self.assertRaises(RuntimeError):
|
|
101
|
+
devin_usage.get_credentials()
|
|
102
|
+
|
|
103
|
+
def test_unauthorized_never_attempts_refresh(self):
|
|
104
|
+
error = urllib.error.HTTPError("https://example.test", 401, "", {}, None)
|
|
105
|
+
with (
|
|
106
|
+
mock.patch.object(
|
|
107
|
+
devin_usage,
|
|
108
|
+
"get_credentials",
|
|
109
|
+
return_value=("expired", "https://server.codeium.com"),
|
|
110
|
+
),
|
|
111
|
+
mock.patch.object(devin_usage, "_devin_version", return_value="1.0.0"),
|
|
112
|
+
mock.patch.object(devin_usage.urllib.request, "urlopen", side_effect=error),
|
|
113
|
+
):
|
|
114
|
+
data = devin_usage.build_usage_json()
|
|
115
|
+
self.assertEqual(data["status"], "unavailable")
|
|
116
|
+
self.assertIn("sign in", data["error"])
|
|
117
|
+
|
|
118
|
+
def test_stale_cache_is_returned_when_refresh_fails(self):
|
|
119
|
+
cached = {
|
|
120
|
+
"provider": "devin",
|
|
121
|
+
"status": "live",
|
|
122
|
+
"source": "devin_user_status_api",
|
|
123
|
+
"retrieved_at": "2020-01-01T00:00:00+00:00",
|
|
124
|
+
"quotas": {"daily": {"remaining_pct": 50}},
|
|
125
|
+
}
|
|
126
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
127
|
+
cache = Path(temporary) / "usage.json"
|
|
128
|
+
cache.write_text(json.dumps(cached))
|
|
129
|
+
with (
|
|
130
|
+
mock.patch.object(devin_usage, "DEFAULT_USAGE_FILE", cache),
|
|
131
|
+
mock.patch.object(
|
|
132
|
+
devin_usage,
|
|
133
|
+
"build_usage_json",
|
|
134
|
+
return_value={"status": "unavailable", "error": "offline"},
|
|
135
|
+
),
|
|
136
|
+
):
|
|
137
|
+
result = devin_usage.get_cached_usage()
|
|
138
|
+
self.assertEqual(result["status"], "stale")
|
|
139
|
+
self.assertEqual(result["refresh_error"], "offline")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
unittest.main()
|