pocketshell 0.3.29__tar.gz → 0.3.30__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.
- {pocketshell-0.3.29 → pocketshell-0.3.30}/PKG-INFO +1 -1
- {pocketshell-0.3.29 → pocketshell-0.3.30}/pyproject.toml +1 -1
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/usage.py +126 -17
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_usage.py +148 -4
- {pocketshell-0.3.29 → pocketshell-0.3.30}/.gitignore +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/README.md +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/__init__.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_agent_log.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_cli.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_env.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_logs.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_repos.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_sessions.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.30}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.30
|
|
4
4
|
Summary: Unified server-side Python utility for the PocketShell Android client.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
|
|
6
6
|
Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
|
|
@@ -8,7 +8,7 @@ name = "pocketshell"
|
|
|
8
8
|
# scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
|
|
9
9
|
# runs that check before publishing to PyPI. See
|
|
10
10
|
# tools/pocketshell/README.md ("Release flow") for the bump procedure.
|
|
11
|
-
version = "0.3.
|
|
11
|
+
version = "0.3.30"
|
|
12
12
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -61,6 +61,17 @@ import click
|
|
|
61
61
|
|
|
62
62
|
_CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
|
|
63
63
|
_CODEX_AUTH_PATH = Path.home() / ".codex" / "auth.json"
|
|
64
|
+
_CODEX_COMPATIBLE_PROVIDERS = {
|
|
65
|
+
"codex",
|
|
66
|
+
"openai",
|
|
67
|
+
"openai-codex",
|
|
68
|
+
"openai_codex",
|
|
69
|
+
"chatgpt",
|
|
70
|
+
}
|
|
71
|
+
_CLAUDE_USAGE_AUTH_SETUP_MESSAGE = (
|
|
72
|
+
"Claude usage authentication needs setup on this host. "
|
|
73
|
+
"Open Claude Code on the host and complete sign-in, then refresh usage."
|
|
74
|
+
)
|
|
64
75
|
|
|
65
76
|
|
|
66
77
|
def _resolve_quse_binary() -> Optional[str]:
|
|
@@ -151,16 +162,78 @@ def _percent_remaining_from_used(value: Any) -> Optional[float]:
|
|
|
151
162
|
return round(max(0.0, min(100.0, 100.0 - used)), 2)
|
|
152
163
|
|
|
153
164
|
|
|
154
|
-
def
|
|
165
|
+
def _reset_after_seconds_to_iso(
|
|
166
|
+
value: Any,
|
|
167
|
+
*,
|
|
168
|
+
now: Optional[datetime] = None,
|
|
169
|
+
) -> Optional[str]:
|
|
170
|
+
if value is None:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
seconds = float(value)
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
return None
|
|
176
|
+
if seconds < 0:
|
|
177
|
+
return None
|
|
178
|
+
base = now or datetime.now(timezone.utc)
|
|
179
|
+
if base.tzinfo is None:
|
|
180
|
+
base = base.replace(tzinfo=timezone.utc)
|
|
181
|
+
reset_at = base.astimezone(timezone.utc).timestamp() + seconds
|
|
182
|
+
try:
|
|
183
|
+
parsed = datetime.fromtimestamp(reset_at, tz=timezone.utc)
|
|
184
|
+
except (OverflowError, OSError, ValueError):
|
|
185
|
+
return None
|
|
186
|
+
return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _window_label_from_seconds(value: Any) -> Optional[str]:
|
|
190
|
+
try:
|
|
191
|
+
seconds = int(float(value))
|
|
192
|
+
except (TypeError, ValueError, OverflowError):
|
|
193
|
+
return None
|
|
194
|
+
if seconds <= 0:
|
|
195
|
+
return None
|
|
196
|
+
units = (
|
|
197
|
+
(24 * 60 * 60, "d"),
|
|
198
|
+
(60 * 60, "h"),
|
|
199
|
+
(60, "m"),
|
|
200
|
+
)
|
|
201
|
+
for unit_seconds, suffix in units:
|
|
202
|
+
if seconds >= unit_seconds and seconds % unit_seconds == 0:
|
|
203
|
+
return f"{seconds // unit_seconds}{suffix}"
|
|
204
|
+
return f"{seconds}s"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _window_label_from_detail(detail: dict[str, Any]) -> Optional[str]:
|
|
208
|
+
label = _window_label_from_seconds(detail.get("limit_window_seconds"))
|
|
209
|
+
if label is not None:
|
|
210
|
+
return label
|
|
211
|
+
try:
|
|
212
|
+
minutes = float(detail.get("window_minutes"))
|
|
213
|
+
except (TypeError, ValueError):
|
|
214
|
+
return None
|
|
215
|
+
return _window_label_from_seconds(minutes * 60)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _window_from_detail(
|
|
219
|
+
detail: Any,
|
|
220
|
+
*,
|
|
221
|
+
now: Optional[datetime] = None,
|
|
222
|
+
) -> Optional[dict[str, Any]]:
|
|
155
223
|
if not isinstance(detail, dict):
|
|
156
224
|
return None
|
|
157
225
|
percent_remaining = _percent_remaining_from_used(detail.get("used_percent"))
|
|
158
|
-
reset_at = _normalize_reset_at(detail.get("reset_at"))
|
|
159
|
-
|
|
226
|
+
reset_at = _normalize_reset_at(detail.get("reset_at")) or _reset_after_seconds_to_iso(
|
|
227
|
+
detail.get("reset_after_seconds"),
|
|
228
|
+
now=now,
|
|
229
|
+
)
|
|
230
|
+
window = _window_label_from_detail(detail)
|
|
231
|
+
if percent_remaining is None and reset_at is None and window is None:
|
|
160
232
|
return None
|
|
161
233
|
return {
|
|
162
234
|
"percent_remaining": percent_remaining,
|
|
163
235
|
"reset_at": reset_at,
|
|
236
|
+
"window": window,
|
|
164
237
|
}
|
|
165
238
|
|
|
166
239
|
|
|
@@ -169,6 +242,7 @@ def _merge_window(
|
|
|
169
242
|
detail: Any,
|
|
170
243
|
*,
|
|
171
244
|
prefer_detail_percent: bool = False,
|
|
245
|
+
now: Optional[datetime] = None,
|
|
172
246
|
) -> Any:
|
|
173
247
|
if not isinstance(current, dict):
|
|
174
248
|
if not isinstance(detail, dict):
|
|
@@ -177,14 +251,21 @@ def _merge_window(
|
|
|
177
251
|
else:
|
|
178
252
|
current = dict(current)
|
|
179
253
|
|
|
180
|
-
detail_window = _window_from_detail(detail)
|
|
254
|
+
detail_window = _window_from_detail(detail, now=now)
|
|
181
255
|
if detail_window is not None:
|
|
182
256
|
if prefer_detail_percent or current.get("percent_remaining") is None:
|
|
183
257
|
current["percent_remaining"] = detail_window.get("percent_remaining")
|
|
184
258
|
if current.get("reset_at") is None:
|
|
185
259
|
current["reset_at"] = detail_window.get("reset_at")
|
|
260
|
+
if current.get("window") is None and detail_window.get("window") is not None:
|
|
261
|
+
current["window"] = detail_window.get("window")
|
|
186
262
|
|
|
187
|
-
|
|
263
|
+
reset_after_seconds = current.get("reset_after_seconds")
|
|
264
|
+
current["reset_at"] = _normalize_reset_at(current.get("reset_at")) or _reset_after_seconds_to_iso(
|
|
265
|
+
reset_after_seconds,
|
|
266
|
+
now=now,
|
|
267
|
+
)
|
|
268
|
+
current.pop("reset_after_seconds", None)
|
|
188
269
|
return current
|
|
189
270
|
|
|
190
271
|
|
|
@@ -195,15 +276,19 @@ def _actionable_error(provider: str, error: Any) -> Optional[str]:
|
|
|
195
276
|
if not text:
|
|
196
277
|
return None
|
|
197
278
|
lower = text.lower()
|
|
279
|
+
if provider == "claude" and (
|
|
280
|
+
"claude " + "/login" in lower
|
|
281
|
+
or "run `claude" in lower
|
|
282
|
+
or "run claude" in lower
|
|
283
|
+
or "authentication " + "failed" in lower
|
|
284
|
+
):
|
|
285
|
+
return _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
|
|
198
286
|
if provider == "claude" and (
|
|
199
287
|
"http error 401" in lower
|
|
200
288
|
or "unauthorized" in lower
|
|
201
289
|
or lower in {"no-credentials", "no credentials"}
|
|
202
290
|
):
|
|
203
|
-
return
|
|
204
|
-
"Claude Code authentication failed on this host. "
|
|
205
|
-
"Run `claude /login` in the host shell, then refresh usage."
|
|
206
|
-
)
|
|
291
|
+
return _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
|
|
207
292
|
if provider == "codex" and lower in {"no auth token", "no-auth-token", "no credentials"}:
|
|
208
293
|
return (
|
|
209
294
|
"Codex authentication is missing on this host. "
|
|
@@ -261,14 +346,30 @@ def _codex_needs_source_patch(record: dict[str, Any], detail_windows: dict[str,
|
|
|
261
346
|
):
|
|
262
347
|
top_level = record.get(top_level_key)
|
|
263
348
|
detail = detail_windows.get(detail_key)
|
|
264
|
-
top_level_reset =
|
|
265
|
-
|
|
349
|
+
top_level_reset = None
|
|
350
|
+
if isinstance(top_level, dict):
|
|
351
|
+
top_level_reset = top_level.get("reset_at")
|
|
352
|
+
if top_level_reset is None:
|
|
353
|
+
top_level_reset = top_level.get("reset_after_seconds")
|
|
354
|
+
detail_reset = None
|
|
355
|
+
if isinstance(detail, dict):
|
|
356
|
+
detail_reset = detail.get("reset_at")
|
|
357
|
+
if detail_reset is None:
|
|
358
|
+
detail_reset = detail.get("reset_after_seconds")
|
|
266
359
|
if top_level_reset is None and detail_reset is None:
|
|
267
360
|
return True
|
|
268
361
|
return False
|
|
269
362
|
|
|
270
363
|
|
|
271
|
-
def
|
|
364
|
+
def _is_codex_compatible_provider(provider: str) -> bool:
|
|
365
|
+
return provider.replace(" ", "_").lower() in _CODEX_COMPATIBLE_PROVIDERS
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def normalize_usage_record(
|
|
369
|
+
record: dict[str, Any],
|
|
370
|
+
*,
|
|
371
|
+
now: Optional[datetime] = None,
|
|
372
|
+
) -> dict[str, Any]:
|
|
272
373
|
"""Normalize a provider record emitted by ``quse --json``.
|
|
273
374
|
|
|
274
375
|
PocketShell owns the app-facing schema even when it delegates provider
|
|
@@ -282,7 +383,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
|
282
383
|
if not isinstance(detail_windows, dict):
|
|
283
384
|
detail_windows = {}
|
|
284
385
|
|
|
285
|
-
if provider
|
|
386
|
+
if _is_codex_compatible_provider(provider):
|
|
286
387
|
# Codex's ChatGPT usage response exposes the real primary/secondary
|
|
287
388
|
# windows under details. Older quse versions hard-code short_term to
|
|
288
389
|
# 100% and lose epoch reset timestamps, which regressed issue #501.
|
|
@@ -297,6 +398,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
|
297
398
|
normalized.get("short_term"),
|
|
298
399
|
detail_windows.get("primary_window"),
|
|
299
400
|
prefer_detail_percent=True,
|
|
401
|
+
now=now,
|
|
300
402
|
)
|
|
301
403
|
if short_term is not None:
|
|
302
404
|
normalized["short_term"] = short_term
|
|
@@ -304,6 +406,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
|
304
406
|
normalized.get("long_term"),
|
|
305
407
|
detail_windows.get("secondary_window"),
|
|
306
408
|
prefer_detail_percent=True,
|
|
409
|
+
now=now,
|
|
307
410
|
)
|
|
308
411
|
if long_term is not None:
|
|
309
412
|
normalized["long_term"] = long_term
|
|
@@ -311,20 +414,22 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
|
311
414
|
short_term = _merge_window(
|
|
312
415
|
normalized.get("short_term"),
|
|
313
416
|
detail_windows.get("five_hour"),
|
|
417
|
+
now=now,
|
|
314
418
|
)
|
|
315
419
|
if short_term is not None:
|
|
316
420
|
normalized["short_term"] = short_term
|
|
317
421
|
long_term = _merge_window(
|
|
318
422
|
normalized.get("long_term"),
|
|
319
423
|
detail_windows.get("seven_day"),
|
|
424
|
+
now=now,
|
|
320
425
|
)
|
|
321
426
|
if long_term is not None:
|
|
322
427
|
normalized["long_term"] = long_term
|
|
323
428
|
else:
|
|
324
429
|
if isinstance(normalized.get("short_term"), dict):
|
|
325
|
-
normalized["short_term"] = _merge_window(normalized.get("short_term"), None)
|
|
430
|
+
normalized["short_term"] = _merge_window(normalized.get("short_term"), None, now=now)
|
|
326
431
|
if isinstance(normalized.get("long_term"), dict):
|
|
327
|
-
normalized["long_term"] = _merge_window(normalized.get("long_term"), None)
|
|
432
|
+
normalized["long_term"] = _merge_window(normalized.get("long_term"), None, now=now)
|
|
328
433
|
|
|
329
434
|
actionable = _actionable_error(provider, normalized.get("error"))
|
|
330
435
|
if actionable != normalized.get("error"):
|
|
@@ -332,7 +437,11 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
|
332
437
|
return normalized
|
|
333
438
|
|
|
334
439
|
|
|
335
|
-
def normalize_usage_stdout(
|
|
440
|
+
def normalize_usage_stdout(
|
|
441
|
+
stdout: str,
|
|
442
|
+
*,
|
|
443
|
+
now: Optional[datetime] = None,
|
|
444
|
+
) -> str:
|
|
336
445
|
"""Normalize NDJSON stdout from ``quse --json`` for app consumption."""
|
|
337
446
|
if not stdout.strip():
|
|
338
447
|
return stdout
|
|
@@ -348,7 +457,7 @@ def normalize_usage_stdout(stdout: str) -> str:
|
|
|
348
457
|
return stdout
|
|
349
458
|
if not isinstance(parsed, dict):
|
|
350
459
|
return stdout
|
|
351
|
-
normalized = normalize_usage_record(parsed)
|
|
460
|
+
normalized = normalize_usage_record(parsed, now=now)
|
|
352
461
|
changed = changed or normalized != parsed
|
|
353
462
|
lines.append(json.dumps(normalized, sort_keys=True))
|
|
354
463
|
suffix = "\n" if stdout.endswith("\n") else ""
|
|
@@ -20,13 +20,19 @@ from __future__ import annotations
|
|
|
20
20
|
|
|
21
21
|
import json
|
|
22
22
|
import subprocess
|
|
23
|
+
from datetime import datetime, timezone
|
|
23
24
|
from typing import Sequence
|
|
24
25
|
from unittest.mock import patch
|
|
25
26
|
|
|
26
27
|
from click.testing import CliRunner
|
|
27
28
|
|
|
28
29
|
from pocketshell.cli import cli, main
|
|
29
|
-
from pocketshell.usage import
|
|
30
|
+
from pocketshell.usage import (
|
|
31
|
+
_CLAUDE_USAGE_AUTH_SETUP_MESSAGE,
|
|
32
|
+
_actionable_error,
|
|
33
|
+
normalize_usage_record,
|
|
34
|
+
usage_command,
|
|
35
|
+
)
|
|
30
36
|
|
|
31
37
|
|
|
32
38
|
def _fake_completed(
|
|
@@ -112,10 +118,12 @@ def test_usage_json_normalizes_codex_detail_windows_and_epoch_resets() -> None:
|
|
|
112
118
|
"windows": {
|
|
113
119
|
"primary_window": {
|
|
114
120
|
"used_percent": 12,
|
|
121
|
+
"limit_window_seconds": 18000,
|
|
115
122
|
"reset_at": 1780828285,
|
|
116
123
|
},
|
|
117
124
|
"secondary_window": {
|
|
118
125
|
"used_percent": 31,
|
|
126
|
+
"limit_window_seconds": 604800,
|
|
119
127
|
"reset_at": 1781137638,
|
|
120
128
|
},
|
|
121
129
|
},
|
|
@@ -148,15 +156,29 @@ def test_usage_json_normalizes_codex_detail_windows_and_epoch_resets() -> None:
|
|
|
148
156
|
assert codex["short_term"] == {
|
|
149
157
|
"percent_remaining": 88.0,
|
|
150
158
|
"reset_at": "2026-06-07T10:31:25Z",
|
|
159
|
+
"window": "5h",
|
|
151
160
|
}
|
|
152
161
|
assert codex["long_term"] == {
|
|
153
162
|
"percent_remaining": 69.0,
|
|
154
163
|
"reset_at": "2026-06-11T00:27:18Z",
|
|
164
|
+
"window": "7d",
|
|
155
165
|
}
|
|
156
|
-
assert
|
|
166
|
+
assert lines[1]["error"] == _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
|
|
167
|
+
assert "claude " + "/login" not in lines[1]["error"]
|
|
168
|
+
assert "authentication " + "failed" not in lines[1]["error"].lower()
|
|
157
169
|
assert "HTTP Error 401" not in lines[1]["error"]
|
|
158
170
|
|
|
159
171
|
|
|
172
|
+
def test_claude_stale_auth_telemetry_error_is_usage_unavailable() -> None:
|
|
173
|
+
stale_error = (
|
|
174
|
+
"Claude Code authentication "
|
|
175
|
+
+ "failed on this host. Run `claude "
|
|
176
|
+
+ "/login` in the host shell."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert _actionable_error("claude", stale_error) == _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
|
|
180
|
+
|
|
181
|
+
|
|
160
182
|
def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() -> None:
|
|
161
183
|
raw = json.dumps(
|
|
162
184
|
{
|
|
@@ -182,8 +204,16 @@ def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() ->
|
|
|
182
204
|
), patch(
|
|
183
205
|
"pocketshell.usage._fetch_codex_detail_windows",
|
|
184
206
|
return_value={
|
|
185
|
-
"primary_window": {
|
|
186
|
-
|
|
207
|
+
"primary_window": {
|
|
208
|
+
"used_percent": 13,
|
|
209
|
+
"limit_window_seconds": 18000,
|
|
210
|
+
"reset_at": 1780828285,
|
|
211
|
+
},
|
|
212
|
+
"secondary_window": {
|
|
213
|
+
"used_percent": 31,
|
|
214
|
+
"limit_window_seconds": 604800,
|
|
215
|
+
"reset_at": 1781137638,
|
|
216
|
+
},
|
|
187
217
|
},
|
|
188
218
|
):
|
|
189
219
|
result = runner.invoke(usage_command, ["--json"])
|
|
@@ -193,10 +223,124 @@ def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() ->
|
|
|
193
223
|
assert codex["short_term"] == {
|
|
194
224
|
"percent_remaining": 87.0,
|
|
195
225
|
"reset_at": "2026-06-07T10:31:25Z",
|
|
226
|
+
"window": "5h",
|
|
196
227
|
}
|
|
197
228
|
assert codex["long_term"] == {
|
|
198
229
|
"percent_remaining": 69.0,
|
|
199
230
|
"reset_at": "2026-06-11T00:27:18Z",
|
|
231
|
+
"window": "7d",
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_usage_json_normalizes_openai_compatible_detail_windows() -> None:
|
|
236
|
+
raw = json.dumps(
|
|
237
|
+
{
|
|
238
|
+
"provider": "openai",
|
|
239
|
+
"status": "ok",
|
|
240
|
+
"short_term": {"percent_remaining": 100.0, "reset_at": None},
|
|
241
|
+
"long_term": {"percent_remaining": 35.0, "reset_at": None},
|
|
242
|
+
"block_reason": None,
|
|
243
|
+
"error": None,
|
|
244
|
+
"details": {
|
|
245
|
+
"windows": {
|
|
246
|
+
"primary_window": {
|
|
247
|
+
"used_percent": 22,
|
|
248
|
+
"limit_window_seconds": 18000,
|
|
249
|
+
"reset_at": "2026-06-08T02:19:59Z",
|
|
250
|
+
},
|
|
251
|
+
"secondary_window": {
|
|
252
|
+
"used_percent": 65,
|
|
253
|
+
"limit_window_seconds": 604800,
|
|
254
|
+
"reset_at": "2026-06-11T00:27:17Z",
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
runner = CliRunner()
|
|
261
|
+
with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
|
|
262
|
+
"pocketshell.usage.subprocess.run",
|
|
263
|
+
return_value=_fake_completed(stdout=raw + "\n"),
|
|
264
|
+
):
|
|
265
|
+
result = runner.invoke(usage_command, ["--json"])
|
|
266
|
+
|
|
267
|
+
assert result.exit_code == 0, result.output
|
|
268
|
+
record = json.loads(result.output)
|
|
269
|
+
assert record["short_term"] == {
|
|
270
|
+
"percent_remaining": 78.0,
|
|
271
|
+
"reset_at": "2026-06-08T02:19:59Z",
|
|
272
|
+
"window": "5h",
|
|
273
|
+
}
|
|
274
|
+
assert record["long_term"] == {
|
|
275
|
+
"percent_remaining": 35.0,
|
|
276
|
+
"reset_at": "2026-06-11T00:27:17Z",
|
|
277
|
+
"window": "7d",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_normalize_usage_record_preserves_codex_reset_after_seconds() -> None:
|
|
282
|
+
record = normalize_usage_record(
|
|
283
|
+
{
|
|
284
|
+
"provider": "codex",
|
|
285
|
+
"status": "ok",
|
|
286
|
+
"short_term": {"percent_remaining": 35.0, "reset_at": None},
|
|
287
|
+
"long_term": {"percent_remaining": 69.0, "reset_at": None},
|
|
288
|
+
"block_reason": None,
|
|
289
|
+
"error": None,
|
|
290
|
+
"details": {
|
|
291
|
+
"windows": {
|
|
292
|
+
"primary_window": {
|
|
293
|
+
"used_percent": 65,
|
|
294
|
+
"window_minutes": 300,
|
|
295
|
+
"reset_after_seconds": 3600,
|
|
296
|
+
},
|
|
297
|
+
"secondary_window": {
|
|
298
|
+
"used_percent": 31,
|
|
299
|
+
"limit_window_seconds": 604800,
|
|
300
|
+
"reset_after_seconds": "604800",
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
now=datetime(2026, 6, 8, 10, 0, tzinfo=timezone.utc),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
assert record["short_term"] == {
|
|
309
|
+
"percent_remaining": 35.0,
|
|
310
|
+
"reset_at": "2026-06-08T11:00:00Z",
|
|
311
|
+
"window": "5h",
|
|
312
|
+
}
|
|
313
|
+
assert record["long_term"] == {
|
|
314
|
+
"percent_remaining": 69.0,
|
|
315
|
+
"reset_at": "2026-06-15T10:00:00Z",
|
|
316
|
+
"window": "7d",
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_normalize_usage_record_converts_top_level_reset_after_seconds() -> None:
|
|
321
|
+
with patch("pocketshell.usage._fetch_codex_detail_windows", return_value=None):
|
|
322
|
+
record = normalize_usage_record(
|
|
323
|
+
{
|
|
324
|
+
"provider": "codex",
|
|
325
|
+
"status": "ok",
|
|
326
|
+
"short_term": {
|
|
327
|
+
"percent_remaining": 35.0,
|
|
328
|
+
"reset_at": None,
|
|
329
|
+
"reset_after_seconds": 3600,
|
|
330
|
+
"window": "5h",
|
|
331
|
+
},
|
|
332
|
+
"long_term": None,
|
|
333
|
+
"block_reason": None,
|
|
334
|
+
"error": None,
|
|
335
|
+
"details": {},
|
|
336
|
+
},
|
|
337
|
+
now=datetime(2026, 6, 8, 10, 0, tzinfo=timezone.utc),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
assert record["short_term"] == {
|
|
341
|
+
"percent_remaining": 35.0,
|
|
342
|
+
"reset_at": "2026-06-08T11:00:00Z",
|
|
343
|
+
"window": "5h",
|
|
200
344
|
}
|
|
201
345
|
|
|
202
346
|
|
|
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
|
|
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
|