cdx-manager 0.7.0 → 0.7.1
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.
- package/README.md +3 -3
- package/changelogs/CHANGELOGS_0_7_1.md +41 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_usage.py +11 -2
- package/src/cli.py +1 -1
- package/src/cli_commands.py +9 -10
- package/src/cli_render.py +3 -1
- package/src/provider_runtime.py +70 -7
- package/src/run_usage.py +133 -0
- package/src/session_service.py +16 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -134,7 +134,7 @@ For a specific version:
|
|
|
134
134
|
|
|
135
135
|
```bash
|
|
136
136
|
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
|
|
137
|
-
CDX_VERSION=v0.7.
|
|
137
|
+
CDX_VERSION=v0.7.1 sh install.sh
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
From source:
|
|
@@ -437,7 +437,7 @@ cdx run \
|
|
|
437
437
|
--json
|
|
438
438
|
```
|
|
439
439
|
|
|
440
|
-
The result includes `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Token counts are `null` when the provider does not expose
|
|
440
|
+
The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
|
|
441
441
|
|
|
442
442
|
`cdx select` exposes the same session selection logic directly:
|
|
443
443
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog (`0.7.0 -> 0.7.1`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-29
|
|
4
|
+
|
|
5
|
+
## Headless Automation
|
|
6
|
+
|
|
7
|
+
- Switched `cdx run --json` Codex execution to the provider-native `codex exec --json` path so headless runs no longer depend on an interactive terminal launch mode.
|
|
8
|
+
- Switched Claude headless execution to `claude --print --output-format json` while preserving isolated auth homes, model selection, permission mode, and effort mapping.
|
|
9
|
+
- Preserved Codex unattended `auto` permission behavior in headless mode with an explicit `approval_policy="never"` config override.
|
|
10
|
+
- Kept provider stdout and stderr captured in artifact files while cdx stdout remains reserved for the final JSON payload.
|
|
11
|
+
|
|
12
|
+
## JSON Contract
|
|
13
|
+
|
|
14
|
+
- Added top-level `launcher: "cdx"` to `cdx run --json` result payloads so subprocess callers can identify cdx-manager responses directly.
|
|
15
|
+
- Ensured `launcher: "cdx"` is also present when provider auto-selection cannot find a suitable session.
|
|
16
|
+
- Added conservative token usage extraction for supported Codex and Claude JSON or JSONL stdout artifact shapes.
|
|
17
|
+
- Preserved all-null token usage when artifacts are missing, malformed, unsupported, or do not expose trusted usage data.
|
|
18
|
+
|
|
19
|
+
## Status Display
|
|
20
|
+
|
|
21
|
+
- Clamped Claude rate-limit percentage calculations to the `0..100` range so over-limit provider utilization cannot display negative remaining availability.
|
|
22
|
+
- Clamped cached status percentages defensively so already-persisted invalid values render as valid availability values.
|
|
23
|
+
- Colored fully depleted or invalid low availability as red and high availability above 80% as bright cyan.
|
|
24
|
+
|
|
25
|
+
## Planning Docs
|
|
26
|
+
|
|
27
|
+
- Added and completed Logics request, backlog items, and tasks for Orchestia headless token usage and provider-native launch modes.
|
|
28
|
+
|
|
29
|
+
## Release Metadata
|
|
30
|
+
|
|
31
|
+
- Updated package metadata, CLI version output, README badge, pinned installer example, and release changelog to `v0.7.1`.
|
|
32
|
+
|
|
33
|
+
## Validation and Regression Evidence
|
|
34
|
+
|
|
35
|
+
- `python -m unittest discover -s test -p 'test_*_py.py'`
|
|
36
|
+
- `npm run lint`
|
|
37
|
+
- `npm test`
|
|
38
|
+
- `npm pack --dry-run`
|
|
39
|
+
- `python -m build`
|
|
40
|
+
- `python -m twine check dist/*`
|
|
41
|
+
- `python3 -m logics_manager lint --require-status`
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"v0.6.5": {
|
|
29
29
|
"github_tarball_sha256": "f2280917ea75b5ae1e99a99b011f09704d89b1a7ae42497f5093e6fff9f814d9",
|
|
30
30
|
"github_zip_sha256": "0b8491037310ed82cf44419d0de887bc0f768c6690c69153992fc4d5cda69676"
|
|
31
|
+
},
|
|
32
|
+
"v0.7.0": {
|
|
33
|
+
"github_tarball_sha256": "c80231884bf20c9ae74144a1ec16e0685c2fdac67d7d93a3c6219c5ac6fc14dd",
|
|
34
|
+
"github_zip_sha256": "0e6af29e59d3a93a07e07656d6c0c93803a674e65830ef33fa2322649b7139c2"
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
37
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/claude_usage.py
CHANGED
|
@@ -87,6 +87,15 @@ def _format_reset_date(unix_seconds):
|
|
|
87
87
|
return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
def _remaining_from_utilization(value):
|
|
91
|
+
if value is None:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return max(0, min(100, round((1 - float(value)) * 100)))
|
|
95
|
+
except (TypeError, ValueError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
90
99
|
def _read_http_error_message(error):
|
|
91
100
|
try:
|
|
92
101
|
body = error.read().decode("utf-8", errors="replace")
|
|
@@ -159,8 +168,8 @@ def fetch_claude_rate_limit_headers(access_token):
|
|
|
159
168
|
reset_at = reset_week_at or reset_5h_at
|
|
160
169
|
|
|
161
170
|
return {
|
|
162
|
-
"remaining_5h_pct":
|
|
163
|
-
"remaining_week_pct":
|
|
171
|
+
"remaining_5h_pct": _remaining_from_utilization(utilization_5h),
|
|
172
|
+
"remaining_week_pct": _remaining_from_utilization(utilization_7d),
|
|
164
173
|
"reset_5h_at": reset_5h_at,
|
|
165
174
|
"reset_week_at": reset_week_at,
|
|
166
175
|
"reset_at": reset_at,
|
package/src/cli.py
CHANGED
package/src/cli_commands.py
CHANGED
|
@@ -40,6 +40,7 @@ from .provider_runtime import (
|
|
|
40
40
|
)
|
|
41
41
|
from .repair import format_repair_report, repair_health
|
|
42
42
|
from .backup_bundle import read_bundle_meta
|
|
43
|
+
from .run_usage import empty_usage, extract_run_usage
|
|
43
44
|
from .status_view import _format_status_detail, _format_status_rows
|
|
44
45
|
from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
|
|
45
46
|
from .update_manager import build_update_plan, format_update_failure, run_update_plan
|
|
@@ -1201,15 +1202,6 @@ def _read_run_prompt(parsed):
|
|
|
1201
1202
|
raise CdxError(f"Unable to read prompt file: {parsed['prompt_file']}") from error
|
|
1202
1203
|
|
|
1203
1204
|
|
|
1204
|
-
def _run_usage_payload():
|
|
1205
|
-
return {
|
|
1206
|
-
"input_tokens": None,
|
|
1207
|
-
"output_tokens": None,
|
|
1208
|
-
"reasoning_tokens": None,
|
|
1209
|
-
"total_tokens": None,
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
1205
|
def _run_cdx_error_code(error):
|
|
1214
1206
|
message = str(error)
|
|
1215
1207
|
if message.startswith("Usage:"):
|
|
@@ -1233,10 +1225,16 @@ def _run_cdx_error_code(error):
|
|
|
1233
1225
|
|
|
1234
1226
|
def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_source=None, error_code=None):
|
|
1235
1227
|
run_info = run_info or {}
|
|
1228
|
+
usage = (
|
|
1229
|
+
extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
|
|
1230
|
+
if ok and session else
|
|
1231
|
+
empty_usage()
|
|
1232
|
+
)
|
|
1236
1233
|
return {
|
|
1237
1234
|
"schema_version": API_SCHEMA_VERSION,
|
|
1238
1235
|
"ok": bool(ok),
|
|
1239
1236
|
"action": "run",
|
|
1237
|
+
"launcher": "cdx",
|
|
1240
1238
|
"session": session.get("name") if session else None,
|
|
1241
1239
|
"provider": session.get("provider") if session else parsed.get("provider"),
|
|
1242
1240
|
"model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
|
|
@@ -1249,7 +1247,7 @@ def _run_result_payload(ok, parsed, session, run_info=None, error=None, error_so
|
|
|
1249
1247
|
"transcript_path": run_info.get("transcript_path"),
|
|
1250
1248
|
"stdout_path": run_info.get("stdout_path"),
|
|
1251
1249
|
"stderr_path": run_info.get("stderr_path"),
|
|
1252
|
-
"usage":
|
|
1250
|
+
"usage": usage,
|
|
1253
1251
|
"warnings": [],
|
|
1254
1252
|
"error": None if ok else {
|
|
1255
1253
|
"source": error_source or "cdx",
|
|
@@ -1283,6 +1281,7 @@ def handle_run(rest, ctx):
|
|
|
1283
1281
|
"run",
|
|
1284
1282
|
"no_suitable_session",
|
|
1285
1283
|
"No suitable session found.",
|
|
1284
|
+
launcher="cdx",
|
|
1286
1285
|
provider=parsed["provider"],
|
|
1287
1286
|
))
|
|
1288
1287
|
return 1
|
package/src/cli_render.py
CHANGED
|
@@ -75,10 +75,12 @@ def _style_pct(value, use_color=False):
|
|
|
75
75
|
text = _format_pct(value)
|
|
76
76
|
if value is None:
|
|
77
77
|
return _style(text, "2", use_color)
|
|
78
|
-
if value
|
|
78
|
+
if value <= 0:
|
|
79
79
|
return _style(text, "31", use_color)
|
|
80
80
|
if value <= 10:
|
|
81
81
|
return _style(text, "33", use_color)
|
|
82
|
+
if value > 80:
|
|
83
|
+
return _style(text, "96", use_color)
|
|
82
84
|
return _style(text, "32", use_color)
|
|
83
85
|
|
|
84
86
|
|
package/src/provider_runtime.py
CHANGED
|
@@ -29,6 +29,12 @@ LAUNCH_PERMISSION_ARGS = {
|
|
|
29
29
|
"full": ["-s", "danger-full-access", "-a", "never"],
|
|
30
30
|
},
|
|
31
31
|
}
|
|
32
|
+
HEADLESS_CODEX_PERMISSION_ARGS = {
|
|
33
|
+
"review": ["-s", "read-only"],
|
|
34
|
+
"default": ["-s", "workspace-write"],
|
|
35
|
+
"auto": ["-s", "workspace-write", "-c", 'approval_policy="never"'],
|
|
36
|
+
"full": ["--dangerously-bypass-approvals-and-sandbox"],
|
|
37
|
+
}
|
|
32
38
|
|
|
33
39
|
|
|
34
40
|
def _home_env_overrides(auth_home):
|
|
@@ -279,11 +285,7 @@ def _default_script_args(transcript_path, spec):
|
|
|
279
285
|
|
|
280
286
|
|
|
281
287
|
def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None, capture_transcript=True):
|
|
282
|
-
|
|
283
|
-
if not isinstance(initial_prompt, str):
|
|
284
|
-
raise CdxError("initial_prompt must be a string.")
|
|
285
|
-
if len(initial_prompt) > 32768:
|
|
286
|
-
raise CdxError("initial_prompt exceeds maximum allowed length.")
|
|
288
|
+
_validate_initial_prompt(initial_prompt)
|
|
287
289
|
cwd = cwd or os.getcwd()
|
|
288
290
|
env_override = env_override or {}
|
|
289
291
|
env = {**os.environ, **env_override}
|
|
@@ -347,6 +349,68 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
347
349
|
}, capture_transcript=capture_transcript, env=env)
|
|
348
350
|
|
|
349
351
|
|
|
352
|
+
def _validate_initial_prompt(initial_prompt):
|
|
353
|
+
if initial_prompt is not None:
|
|
354
|
+
if not isinstance(initial_prompt, str):
|
|
355
|
+
raise CdxError("initial_prompt must be a string.")
|
|
356
|
+
if len(initial_prompt) > 32768:
|
|
357
|
+
raise CdxError("initial_prompt exceeds maximum allowed length.")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_prompt=None):
|
|
361
|
+
_validate_initial_prompt(initial_prompt)
|
|
362
|
+
cwd = cwd or os.getcwd()
|
|
363
|
+
env = {**os.environ, **(env_override or {})}
|
|
364
|
+
launch = session.get("launch") or {}
|
|
365
|
+
power = _launch_power(session)
|
|
366
|
+
permission = launch.get("permission")
|
|
367
|
+
model = launch.get("model")
|
|
368
|
+
|
|
369
|
+
if session["provider"] == PROVIDER_CLAUDE:
|
|
370
|
+
args = ["--print", "--output-format", "json", "--name", session["name"]]
|
|
371
|
+
if model:
|
|
372
|
+
args += ["--model", model]
|
|
373
|
+
args += _launch_config_args(session)
|
|
374
|
+
if initial_prompt:
|
|
375
|
+
args.append(initial_prompt)
|
|
376
|
+
auth_home = _get_auth_home(session)
|
|
377
|
+
claude_env = _claude_env(env, auth_home)
|
|
378
|
+
oauth_token = _read_anthropic_oauth_token(auth_home)
|
|
379
|
+
if oauth_token:
|
|
380
|
+
claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
|
381
|
+
return {
|
|
382
|
+
"command": "claude",
|
|
383
|
+
"args": args,
|
|
384
|
+
"options": {"cwd": cwd, "env": claude_env},
|
|
385
|
+
"label": "claude",
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if session["provider"] == PROVIDER_CODEX:
|
|
389
|
+
args = ["exec", "--json", "-C", cwd]
|
|
390
|
+
if model:
|
|
391
|
+
args += ["-m", model]
|
|
392
|
+
if power:
|
|
393
|
+
args += ["-c", f'model_reasoning_effort="{power}"']
|
|
394
|
+
if permission:
|
|
395
|
+
args += HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
|
|
396
|
+
if initial_prompt:
|
|
397
|
+
args.append(initial_prompt)
|
|
398
|
+
return {
|
|
399
|
+
"command": "codex",
|
|
400
|
+
"args": args,
|
|
401
|
+
"options": {"cwd": cwd, "env": {**env, "CODEX_HOME": _get_auth_home(session)}},
|
|
402
|
+
"label": "codex",
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return _build_launch_spec(
|
|
406
|
+
session,
|
|
407
|
+
cwd=cwd,
|
|
408
|
+
env_override=env_override,
|
|
409
|
+
initial_prompt=initial_prompt,
|
|
410
|
+
capture_transcript=False,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
350
414
|
def _headless_artifact_paths(session, run_id=None):
|
|
351
415
|
run_id = run_id or uuid.uuid4().hex
|
|
352
416
|
log_dir = _get_launch_transcript_dir(session)
|
|
@@ -397,12 +461,11 @@ def _headless_run_info(paths, spec, start_time, returncode):
|
|
|
397
461
|
def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
|
|
398
462
|
timeout_seconds=None, spawn=None, run_id=None):
|
|
399
463
|
spawn = spawn or subprocess.Popen
|
|
400
|
-
spec =
|
|
464
|
+
spec = _build_headless_launch_spec(
|
|
401
465
|
session,
|
|
402
466
|
cwd=cwd,
|
|
403
467
|
env_override=env_override,
|
|
404
468
|
initial_prompt=initial_prompt,
|
|
405
|
-
capture_transcript=False,
|
|
406
469
|
)
|
|
407
470
|
paths = _headless_artifact_paths(session, run_id=run_id)
|
|
408
471
|
start_time = datetime.now(timezone.utc)
|
package/src/run_usage.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
USAGE_KEYS = ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
5
|
+
SUPPORTED_PROVIDERS = {"claude", "codex"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def empty_usage():
|
|
9
|
+
return {key: None for key in USAGE_KEYS}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def extract_run_usage(provider, stdout_path):
|
|
13
|
+
if not stdout_path or not provider:
|
|
14
|
+
return empty_usage()
|
|
15
|
+
if provider not in SUPPORTED_PROVIDERS:
|
|
16
|
+
return empty_usage()
|
|
17
|
+
try:
|
|
18
|
+
with open(stdout_path, "r", encoding="utf-8", errors="replace") as handle:
|
|
19
|
+
text = handle.read()
|
|
20
|
+
except OSError:
|
|
21
|
+
return empty_usage()
|
|
22
|
+
if not text.strip():
|
|
23
|
+
return empty_usage()
|
|
24
|
+
|
|
25
|
+
records = _parse_json_records(text)
|
|
26
|
+
if not records:
|
|
27
|
+
return empty_usage()
|
|
28
|
+
|
|
29
|
+
usage = _extract_usage_from_records(records)
|
|
30
|
+
if not _has_usage(usage):
|
|
31
|
+
return empty_usage()
|
|
32
|
+
return usage
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_json_records(text):
|
|
36
|
+
stripped = text.strip()
|
|
37
|
+
try:
|
|
38
|
+
return [json.loads(stripped)]
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
records = []
|
|
43
|
+
for line in stripped.splitlines():
|
|
44
|
+
line = line.strip()
|
|
45
|
+
if not line:
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
records.append(json.loads(line))
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
return []
|
|
51
|
+
return records
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_usage_from_records(records):
|
|
55
|
+
latest = None
|
|
56
|
+
for record in records:
|
|
57
|
+
candidate = _find_usage(record)
|
|
58
|
+
if _has_usage(candidate):
|
|
59
|
+
latest = candidate
|
|
60
|
+
return latest or empty_usage()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_usage(value):
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
direct = _usage_from_dict(value)
|
|
66
|
+
if _has_usage(direct):
|
|
67
|
+
return direct
|
|
68
|
+
for child in value.values():
|
|
69
|
+
found = _find_usage(child)
|
|
70
|
+
if _has_usage(found):
|
|
71
|
+
return found
|
|
72
|
+
if isinstance(value, list):
|
|
73
|
+
for child in value:
|
|
74
|
+
found = _find_usage(child)
|
|
75
|
+
if _has_usage(found):
|
|
76
|
+
return found
|
|
77
|
+
return empty_usage()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _usage_from_dict(value):
|
|
81
|
+
usage = value.get("usage") if isinstance(value.get("usage"), dict) else value
|
|
82
|
+
if not isinstance(usage, dict):
|
|
83
|
+
return empty_usage()
|
|
84
|
+
|
|
85
|
+
input_tokens = _int_value(
|
|
86
|
+
usage.get("input_tokens"),
|
|
87
|
+
usage.get("prompt_tokens"),
|
|
88
|
+
usage.get("cache_creation_input_tokens"),
|
|
89
|
+
usage.get("cache_read_input_tokens"),
|
|
90
|
+
)
|
|
91
|
+
output_tokens = _int_value(usage.get("output_tokens"), usage.get("completion_tokens"))
|
|
92
|
+
reasoning_tokens = _int_value(
|
|
93
|
+
usage.get("reasoning_tokens"),
|
|
94
|
+
_nested_int(usage, "output_tokens_details", "reasoning_tokens"),
|
|
95
|
+
_nested_int(usage, "completion_tokens_details", "reasoning_tokens"),
|
|
96
|
+
)
|
|
97
|
+
total_tokens = _first_int(usage.get("total_tokens"))
|
|
98
|
+
if total_tokens is None and input_tokens is not None and output_tokens is not None:
|
|
99
|
+
total_tokens = input_tokens + output_tokens
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"input_tokens": input_tokens,
|
|
103
|
+
"output_tokens": output_tokens,
|
|
104
|
+
"reasoning_tokens": reasoning_tokens,
|
|
105
|
+
"total_tokens": total_tokens,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _nested_int(value, parent, child):
|
|
110
|
+
nested = value.get(parent)
|
|
111
|
+
if not isinstance(nested, dict):
|
|
112
|
+
return None
|
|
113
|
+
return nested.get(child)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _first_int(value):
|
|
117
|
+
try:
|
|
118
|
+
parsed = int(value)
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
return None
|
|
121
|
+
return parsed if parsed >= 0 else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _int_value(*values):
|
|
125
|
+
parsed = [_first_int(value) for value in values]
|
|
126
|
+
parsed = [value for value in parsed if value is not None]
|
|
127
|
+
if not parsed:
|
|
128
|
+
return None
|
|
129
|
+
return sum(parsed)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _has_usage(usage):
|
|
133
|
+
return isinstance(usage, dict) and any(usage.get(key) is not None for key in USAGE_KEYS)
|
package/src/session_service.py
CHANGED
|
@@ -189,14 +189,23 @@ def _to_local_iso(value):
|
|
|
189
189
|
return parsed.astimezone().isoformat()
|
|
190
190
|
|
|
191
191
|
|
|
192
|
+
def _normalize_pct_value(value):
|
|
193
|
+
if value is None:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
return max(0, min(100, round(float(value))))
|
|
197
|
+
except (TypeError, ValueError):
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
192
201
|
def _normalize_status_payload(payload=None):
|
|
193
202
|
if payload is None:
|
|
194
203
|
payload = {}
|
|
195
204
|
now = _local_now_iso()
|
|
196
205
|
return {
|
|
197
|
-
"usage_pct": payload.get("usage_pct"),
|
|
198
|
-
"remaining_5h_pct": payload.get("remaining_5h_pct"),
|
|
199
|
-
"remaining_week_pct": payload.get("remaining_week_pct"),
|
|
206
|
+
"usage_pct": _normalize_pct_value(payload.get("usage_pct")),
|
|
207
|
+
"remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
|
|
208
|
+
"remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
|
|
200
209
|
"credits": payload.get("credits"),
|
|
201
210
|
"reset_5h_at": payload.get("reset_5h_at"),
|
|
202
211
|
"reset_week_at": payload.get("reset_week_at"),
|
|
@@ -297,8 +306,8 @@ def _compute_available_pct(status):
|
|
|
297
306
|
if not status:
|
|
298
307
|
return None
|
|
299
308
|
values = [
|
|
300
|
-
status.get("remaining_5h_pct"),
|
|
301
|
-
status.get("remaining_week_pct"),
|
|
309
|
+
_normalize_pct_value(status.get("remaining_5h_pct")),
|
|
310
|
+
_normalize_pct_value(status.get("remaining_week_pct")),
|
|
302
311
|
]
|
|
303
312
|
values = [value for value in values if value is not None]
|
|
304
313
|
if not values:
|
|
@@ -981,8 +990,8 @@ def create_session_service(options=None):
|
|
|
981
990
|
"status": "enabled" if enabled else "disabled",
|
|
982
991
|
"auth_status": (s.get("auth") or {}).get("status") or "unknown",
|
|
983
992
|
"auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
|
|
984
|
-
"remaining_5h_pct": row_status.get("remaining_5h_pct") if row_status else None,
|
|
985
|
-
"remaining_week_pct": row_status.get("remaining_week_pct") if row_status else None,
|
|
993
|
+
"remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
|
|
994
|
+
"remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
|
|
986
995
|
"credits": row_status.get("credits") if row_status else None,
|
|
987
996
|
"available_pct": _compute_available_pct(row_status),
|
|
988
997
|
"reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
|