devin-cli-usage 0.1.0__tar.gz → 0.1.1__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 → devin_cli_usage-0.1.1}/PKG-INFO +9 -8
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/README.md +7 -6
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/pyproject.toml +1 -1
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/src/devin_cli_usage/__init__.py +55 -13
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/tests/test_devin_cli_usage.py +50 -0
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/uv.lock +1 -1
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/.github/workflows/publish.yml +0 -0
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/.gitignore +0 -0
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/dist/.gitignore +0 -0
- {devin_cli_usage-0.1.0 → devin_cli_usage-0.1.1}/src/devin_cli_usage/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: devin-cli-usage
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Devin CLI account quota monitor using local Devin authentication
|
|
5
5
|
Project-URL: Homepage, https://github.com/wakamex/devin-cli-usage
|
|
6
6
|
Project-URL: Source, https://github.com/wakamex/devin-cli-usage
|
|
@@ -59,10 +59,10 @@ uv tool install .
|
|
|
59
59
|
## Authentication and data
|
|
60
60
|
|
|
61
61
|
The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
|
|
62
|
-
`~/.local/share/devin/credentials.toml`,
|
|
63
|
-
`
|
|
64
|
-
|
|
65
|
-
Devin-owned files.
|
|
62
|
+
`~/.local/share/devin/credentials.toml`, or the corresponding path below
|
|
63
|
+
`XDG_DATA_HOME` when it is set, then calls the read-only `GetUserStatus`
|
|
64
|
+
service used by the installed client. It rereads credentials for every live
|
|
65
|
+
request. It never refreshes credentials or modifies Devin-owned files.
|
|
66
66
|
|
|
67
67
|
The normalized JSON includes:
|
|
68
68
|
|
|
@@ -77,8 +77,9 @@ vendor sentinel for an unlimited or non-credit-based allowance and is
|
|
|
77
77
|
preserved verbatim.
|
|
78
78
|
|
|
79
79
|
The tool writes only its own non-secret cache at
|
|
80
|
-
`~/.local/share/devin/usage-limits.json
|
|
81
|
-
`live`, `cached`, `stale`, or
|
|
80
|
+
`~/.local/share/devin/usage-limits.json`, also following `XDG_DATA_HOME` when
|
|
81
|
+
set. JSON results report whether they are `live`, `cached`, `stale`, or
|
|
82
|
+
`unavailable`.
|
|
82
83
|
|
|
83
84
|
Environment overrides:
|
|
84
85
|
|
|
@@ -50,10 +50,10 @@ uv tool install .
|
|
|
50
50
|
## Authentication and data
|
|
51
51
|
|
|
52
52
|
The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
|
|
53
|
-
`~/.local/share/devin/credentials.toml`,
|
|
54
|
-
`
|
|
55
|
-
|
|
56
|
-
Devin-owned files.
|
|
53
|
+
`~/.local/share/devin/credentials.toml`, or the corresponding path below
|
|
54
|
+
`XDG_DATA_HOME` when it is set, then calls the read-only `GetUserStatus`
|
|
55
|
+
service used by the installed client. It rereads credentials for every live
|
|
56
|
+
request. It never refreshes credentials or modifies Devin-owned files.
|
|
57
57
|
|
|
58
58
|
The normalized JSON includes:
|
|
59
59
|
|
|
@@ -68,8 +68,9 @@ vendor sentinel for an unlimited or non-credit-based allowance and is
|
|
|
68
68
|
preserved verbatim.
|
|
69
69
|
|
|
70
70
|
The tool writes only its own non-secret cache at
|
|
71
|
-
`~/.local/share/devin/usage-limits.json
|
|
72
|
-
`live`, `cached`, `stale`, or
|
|
71
|
+
`~/.local/share/devin/usage-limits.json`, also following `XDG_DATA_HOME` when
|
|
72
|
+
set. JSON results report whether they are `live`, `cached`, `stale`, or
|
|
73
|
+
`unavailable`.
|
|
73
74
|
|
|
74
75
|
Environment overrides:
|
|
75
76
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
|
+
import math
|
|
8
9
|
import os
|
|
9
10
|
import re
|
|
10
11
|
import shutil
|
|
@@ -46,17 +47,31 @@ def _parse_iso(value: object) -> datetime | None:
|
|
|
46
47
|
def _number(value: object) -> int | float | None:
|
|
47
48
|
if isinstance(value, bool):
|
|
48
49
|
return None
|
|
49
|
-
if isinstance(value, int
|
|
50
|
+
if isinstance(value, int):
|
|
50
51
|
return value
|
|
52
|
+
if isinstance(value, float):
|
|
53
|
+
return value if math.isfinite(value) else None
|
|
51
54
|
if isinstance(value, str):
|
|
52
55
|
try:
|
|
53
56
|
parsed = float(value)
|
|
54
57
|
except ValueError:
|
|
55
58
|
return None
|
|
59
|
+
if not math.isfinite(parsed):
|
|
60
|
+
return None
|
|
56
61
|
return int(parsed) if parsed.is_integer() else parsed
|
|
57
62
|
return None
|
|
58
63
|
|
|
59
64
|
|
|
65
|
+
def _optional_number(data: dict, key: str) -> int | float | None:
|
|
66
|
+
raw = data.get(key)
|
|
67
|
+
if raw is None:
|
|
68
|
+
return None
|
|
69
|
+
value = _number(raw)
|
|
70
|
+
if value is None:
|
|
71
|
+
raise RuntimeError(f"Devin user-status response contained invalid {key}")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
60
75
|
def _format_reset(value: object) -> str:
|
|
61
76
|
seconds = _number(value)
|
|
62
77
|
if seconds is None:
|
|
@@ -75,12 +90,26 @@ def _format_reset(value: object) -> str:
|
|
|
75
90
|
|
|
76
91
|
def get_credentials_file() -> Path:
|
|
77
92
|
override = os.environ.get("DEVIN_USAGE_CREDENTIALS_FILE")
|
|
78
|
-
|
|
93
|
+
if override:
|
|
94
|
+
return Path(override).expanduser()
|
|
95
|
+
data_home = os.environ.get("XDG_DATA_HOME")
|
|
96
|
+
return (
|
|
97
|
+
Path(data_home).expanduser() / "devin" / "credentials.toml"
|
|
98
|
+
if data_home
|
|
99
|
+
else DEFAULT_CREDENTIALS_FILE
|
|
100
|
+
)
|
|
79
101
|
|
|
80
102
|
|
|
81
103
|
def get_usage_file() -> Path:
|
|
82
104
|
override = os.environ.get("DEVIN_USAGE_FILE")
|
|
83
|
-
|
|
105
|
+
if override:
|
|
106
|
+
return Path(override).expanduser()
|
|
107
|
+
data_home = os.environ.get("XDG_DATA_HOME")
|
|
108
|
+
return (
|
|
109
|
+
Path(data_home).expanduser() / "devin" / "usage-limits.json"
|
|
110
|
+
if data_home
|
|
111
|
+
else DEFAULT_USAGE_FILE
|
|
112
|
+
)
|
|
84
113
|
|
|
85
114
|
|
|
86
115
|
def get_credentials() -> tuple[str, str]:
|
|
@@ -168,17 +197,22 @@ def _normalized_plan_info(payload: dict, plan_status: dict) -> dict:
|
|
|
168
197
|
info = plan_status.get("planInfo")
|
|
169
198
|
if not isinstance(info, dict):
|
|
170
199
|
info = {}
|
|
171
|
-
|
|
200
|
+
normalized = {
|
|
172
201
|
key: value
|
|
173
202
|
for key, source in (
|
|
174
203
|
("name", "planName"),
|
|
175
204
|
("billing_strategy", "billingStrategy"),
|
|
176
|
-
("monthly_prompt_credits", "monthlyPromptCredits"),
|
|
177
|
-
("monthly_flow_credits", "monthlyFlowCredits"),
|
|
178
|
-
("monthly_flex_credits", "monthlyFlexCreditPurchaseAmount"),
|
|
179
205
|
)
|
|
180
206
|
if (value := info.get(source)) is not None
|
|
181
207
|
}
|
|
208
|
+
for key, source in (
|
|
209
|
+
("monthly_prompt_credits", "monthlyPromptCredits"),
|
|
210
|
+
("monthly_flow_credits", "monthlyFlowCredits"),
|
|
211
|
+
("monthly_flex_credits", "monthlyFlexCreditPurchaseAmount"),
|
|
212
|
+
):
|
|
213
|
+
if (value := _optional_number(info, source)) is not None:
|
|
214
|
+
normalized[key] = value
|
|
215
|
+
return normalized
|
|
182
216
|
|
|
183
217
|
|
|
184
218
|
def _normalize(payload: dict) -> dict:
|
|
@@ -187,15 +221,19 @@ def _normalize(payload: dict) -> dict:
|
|
|
187
221
|
raise RuntimeError("Devin user-status response did not contain userStatus")
|
|
188
222
|
plan_status = user_status.get("planStatus")
|
|
189
223
|
if not isinstance(plan_status, dict):
|
|
190
|
-
|
|
224
|
+
raise RuntimeError("Devin user-status response did not contain planStatus")
|
|
191
225
|
quotas = {
|
|
192
226
|
"daily": {
|
|
193
|
-
"remaining_pct":
|
|
194
|
-
|
|
227
|
+
"remaining_pct": _optional_number(
|
|
228
|
+
plan_status, "dailyQuotaRemainingPercent"
|
|
229
|
+
),
|
|
230
|
+
"reset_at": _optional_number(plan_status, "dailyQuotaResetAtUnix"),
|
|
195
231
|
},
|
|
196
232
|
"weekly": {
|
|
197
|
-
"remaining_pct":
|
|
198
|
-
|
|
233
|
+
"remaining_pct": _optional_number(
|
|
234
|
+
plan_status, "weeklyQuotaRemainingPercent"
|
|
235
|
+
),
|
|
236
|
+
"reset_at": _optional_number(plan_status, "weeklyQuotaResetAtUnix"),
|
|
199
237
|
},
|
|
200
238
|
}
|
|
201
239
|
credits = {
|
|
@@ -211,8 +249,12 @@ def _normalize(payload: dict) -> dict:
|
|
|
211
249
|
("acu_consumed", "acuConsumed"),
|
|
212
250
|
("acu_limit", "acuLimit"),
|
|
213
251
|
)
|
|
214
|
-
if (value :=
|
|
252
|
+
if (value := _optional_number(plan_status, source)) is not None
|
|
215
253
|
}
|
|
254
|
+
if not credits and not any(
|
|
255
|
+
quota["remaining_pct"] is not None for quota in quotas.values()
|
|
256
|
+
):
|
|
257
|
+
raise RuntimeError("Devin user-status response did not contain usage data")
|
|
216
258
|
return {
|
|
217
259
|
"plan": _normalized_plan_info(payload, plan_status),
|
|
218
260
|
"plan_period": {
|
|
@@ -25,6 +25,23 @@ class FakeResponse:
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class DevinUsageTests(unittest.TestCase):
|
|
28
|
+
def test_default_files_follow_xdg_data_home(self):
|
|
29
|
+
with (
|
|
30
|
+
tempfile.TemporaryDirectory() as temporary,
|
|
31
|
+
mock.patch.dict(
|
|
32
|
+
devin_usage.os.environ,
|
|
33
|
+
{"XDG_DATA_HOME": temporary},
|
|
34
|
+
clear=True,
|
|
35
|
+
),
|
|
36
|
+
):
|
|
37
|
+
data_dir = Path(temporary) / "devin"
|
|
38
|
+
self.assertEqual(
|
|
39
|
+
devin_usage.get_credentials_file(), data_dir / "credentials.toml"
|
|
40
|
+
)
|
|
41
|
+
self.assertEqual(
|
|
42
|
+
devin_usage.get_usage_file(), data_dir / "usage-limits.json"
|
|
43
|
+
)
|
|
44
|
+
|
|
28
45
|
def test_credentials_are_reread_without_writes(self):
|
|
29
46
|
with tempfile.TemporaryDirectory() as temporary:
|
|
30
47
|
credentials = Path(temporary) / "credentials.toml"
|
|
@@ -88,6 +105,39 @@ class DevinUsageTests(unittest.TestCase):
|
|
|
88
105
|
self.assertEqual(data["quotas"]["weekly"]["remaining_pct"], 50)
|
|
89
106
|
self.assertEqual(data["credits"]["available_prompt"], 900)
|
|
90
107
|
|
|
108
|
+
def test_incomplete_plan_status_is_unavailable(self):
|
|
109
|
+
for plan_status in (None, [], {}):
|
|
110
|
+
with self.subTest(plan_status=plan_status):
|
|
111
|
+
payload = {"userStatus": {"planStatus": plan_status}}
|
|
112
|
+
with mock.patch.object(
|
|
113
|
+
devin_usage, "fetch_usage", return_value=payload
|
|
114
|
+
):
|
|
115
|
+
data = devin_usage.build_usage_json()
|
|
116
|
+
self.assertEqual(data["status"], "unavailable")
|
|
117
|
+
|
|
118
|
+
def test_nonfinite_usage_is_unavailable(self):
|
|
119
|
+
payloads = (
|
|
120
|
+
{
|
|
121
|
+
"userStatus": {
|
|
122
|
+
"planStatus": {"dailyQuotaRemainingPercent": float("inf")}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"planInfo": {"monthlyPromptCredits": "NaN"},
|
|
127
|
+
"userStatus": {
|
|
128
|
+
"planStatus": {"dailyQuotaRemainingPercent": 75}
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
for payload in payloads:
|
|
133
|
+
with self.subTest(payload=payload):
|
|
134
|
+
with mock.patch.object(
|
|
135
|
+
devin_usage, "fetch_usage", return_value=payload
|
|
136
|
+
):
|
|
137
|
+
data = devin_usage.build_usage_json()
|
|
138
|
+
self.assertEqual(data["status"], "unavailable")
|
|
139
|
+
json.dumps(data, allow_nan=False)
|
|
140
|
+
|
|
91
141
|
def test_non_https_server_is_rejected(self):
|
|
92
142
|
with tempfile.TemporaryDirectory() as temporary:
|
|
93
143
|
credentials = Path(temporary) / "credentials.toml"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|