agentpool-cli 0.1.0__py3-none-any.whl
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.
- agentpool/__init__.py +3 -0
- agentpool/agent_io.py +134 -0
- agentpool/artifacts.py +151 -0
- agentpool/cli.py +1199 -0
- agentpool/config.py +373 -0
- agentpool/docs/agentpool-skill.md +85 -0
- agentpool/docs/onboarding.md +169 -0
- agentpool/event_detection.py +150 -0
- agentpool/fixtures/__init__.py +1 -0
- agentpool/fixtures/fake_agents/__init__.py +1 -0
- agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_common.py +44 -0
- agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
- agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
- agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
- agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
- agentpool/git_worktree.py +144 -0
- agentpool/mcp/__init__.py +1 -0
- agentpool/mcp/resources.py +64 -0
- agentpool/mcp/tools.py +259 -0
- agentpool/mcp_server.py +487 -0
- agentpool/models.py +310 -0
- agentpool/onboarding.py +1279 -0
- agentpool/policy.py +63 -0
- agentpool/provider_model_catalog.json +997 -0
- agentpool/providers/__init__.py +3 -0
- agentpool/providers/base.py +411 -0
- agentpool/providers/registry.py +139 -0
- agentpool/redaction.py +30 -0
- agentpool/runtimes/__init__.py +3 -0
- agentpool/runtimes/base.py +36 -0
- agentpool/runtimes/tmux.py +133 -0
- agentpool/session_manager.py +1061 -0
- agentpool/stats/__init__.py +6 -0
- agentpool/stats/card.py +74 -0
- agentpool/stats/compute.py +496 -0
- agentpool/stats/queries.py +138 -0
- agentpool/stats/render.py +103 -0
- agentpool/stats/window.py +85 -0
- agentpool/store.py +478 -0
- agentpool/usage/__init__.py +1 -0
- agentpool/usage/_common.py +223 -0
- agentpool/usage/ccusage.py +130 -0
- agentpool/usage/claude.py +23 -0
- agentpool/usage/codex.py +210 -0
- agentpool/usage/codexbar.py +186 -0
- agentpool/usage/combine.py +71 -0
- agentpool/usage/copilot.py +146 -0
- agentpool/usage/devin.py +265 -0
- agentpool/usage/parsers.py +41 -0
- agentpool/usage/probes.py +52 -0
- agentpool/usage/provider_parsers.py +276 -0
- agentpool/usage/summary.py +166 -0
- agentpool/utils.py +59 -0
- agentpool_cli-0.1.0.dist-info/METADATA +292 -0
- agentpool_cli-0.1.0.dist-info/RECORD +60 -0
- agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
- agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agentpool.models import CapacitySnapshot, Confidence, UsageStatus, UsageWindow, UsageWindowKind
|
|
10
|
+
from agentpool.usage._common import (
|
|
11
|
+
ProbeError,
|
|
12
|
+
_clamp_percent,
|
|
13
|
+
_extract_json_payload,
|
|
14
|
+
_number,
|
|
15
|
+
_parse_datetime,
|
|
16
|
+
unavailable,
|
|
17
|
+
unknown,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect_ccusage(binary: str | None = None) -> dict[str, Any]:
|
|
22
|
+
command = _ccusage_command(binary)
|
|
23
|
+
if not command:
|
|
24
|
+
return {"installed": False, "path": None, "version": None, "safe_source": "local_claude_code_logs"}
|
|
25
|
+
version = None
|
|
26
|
+
try:
|
|
27
|
+
proc = subprocess.run([*command, "--version"], capture_output=True, text=True, timeout=5, check=False)
|
|
28
|
+
if proc.returncode == 0:
|
|
29
|
+
version = (proc.stdout or proc.stderr).strip().splitlines()[0][:200]
|
|
30
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
31
|
+
version = None
|
|
32
|
+
return {
|
|
33
|
+
"installed": True,
|
|
34
|
+
"path": command[0],
|
|
35
|
+
"command": command,
|
|
36
|
+
"version": version,
|
|
37
|
+
"safe_source": "local_claude_code_logs",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ccusage_usage_snapshot(provider_id: str, binary: str | None = None) -> CapacitySnapshot:
|
|
42
|
+
if provider_id != "claude-code":
|
|
43
|
+
return unknown(provider_id, f"ccusage is only mapped for claude-code, not {provider_id}.", source="ccusage")
|
|
44
|
+
command = _ccusage_command(binary)
|
|
45
|
+
if not command:
|
|
46
|
+
return unavailable(
|
|
47
|
+
provider_id,
|
|
48
|
+
"ccusage CLI is not installed. Set AGENTPOOL_CCUSAGE_COMMAND to an explicit command if desired.",
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
proc = subprocess.run(
|
|
52
|
+
[*command, "blocks", "--json", "--offline", "--active", "--no-color"],
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=45,
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
59
|
+
return unknown(provider_id, f"ccusage probe failed: {exc}", source="ccusage")
|
|
60
|
+
text = "\n".join(part for part in [proc.stdout, proc.stderr] if part)
|
|
61
|
+
try:
|
|
62
|
+
payload = _extract_json_payload(text)
|
|
63
|
+
snapshot = parse_ccusage_blocks(provider_id, payload)
|
|
64
|
+
if proc.returncode != 0:
|
|
65
|
+
snapshot.warnings.append(f"ccusage exited with status {proc.returncode}.")
|
|
66
|
+
return snapshot
|
|
67
|
+
except ProbeError as exc:
|
|
68
|
+
return unknown(provider_id, f"ccusage probe failed: {exc}", source="ccusage")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_ccusage_blocks(provider_id: str, payload: Any) -> CapacitySnapshot:
|
|
72
|
+
if not isinstance(payload, dict):
|
|
73
|
+
raise ProbeError("ccusage output must be a JSON object.")
|
|
74
|
+
blocks = payload.get("blocks")
|
|
75
|
+
if not isinstance(blocks, list):
|
|
76
|
+
raise ProbeError("ccusage output did not include blocks.")
|
|
77
|
+
active = None
|
|
78
|
+
for block in blocks:
|
|
79
|
+
if isinstance(block, dict) and block.get("isActive") is True and not block.get("isGap"):
|
|
80
|
+
active = block
|
|
81
|
+
if active is None:
|
|
82
|
+
raise ProbeError("ccusage output did not include an active usage block.")
|
|
83
|
+
projection = active.get("projection") if isinstance(active.get("projection"), dict) else {}
|
|
84
|
+
burn_rate = active.get("burnRate") if isinstance(active.get("burnRate"), dict) else {}
|
|
85
|
+
token_counts = active.get("tokenCounts") if isinstance(active.get("tokenCounts"), dict) else {}
|
|
86
|
+
token_limit_status = active.get("tokenLimitStatus") if isinstance(active.get("tokenLimitStatus"), dict) else {}
|
|
87
|
+
token_limit_used = _number(token_limit_status.get("percentUsed"))
|
|
88
|
+
window = UsageWindow(
|
|
89
|
+
name="active_block",
|
|
90
|
+
kind=UsageWindowKind.FIVE_HOUR,
|
|
91
|
+
status="active_local_log_block",
|
|
92
|
+
used_percent=_clamp_percent(token_limit_used) if token_limit_used is not None else None,
|
|
93
|
+
used_units=_number(active.get("totalTokens")),
|
|
94
|
+
reset_at=_parse_datetime(active.get("endTime")),
|
|
95
|
+
confidence=Confidence.OBSERVED,
|
|
96
|
+
raw_text="ccusage:blocks:active",
|
|
97
|
+
)
|
|
98
|
+
raw = {
|
|
99
|
+
"source": "ccusage_local_logs",
|
|
100
|
+
"block_id": active.get("id"),
|
|
101
|
+
"start_time": active.get("startTime"),
|
|
102
|
+
"actual_end_time": active.get("actualEndTime"),
|
|
103
|
+
"entries": active.get("entries"),
|
|
104
|
+
"cost_usd": _number(active.get("costUSD")),
|
|
105
|
+
"models": active.get("models") if isinstance(active.get("models"), list) else [],
|
|
106
|
+
"token_counts": token_counts,
|
|
107
|
+
"burn_rate": burn_rate,
|
|
108
|
+
"projection": projection,
|
|
109
|
+
"token_limit_status": token_limit_status,
|
|
110
|
+
}
|
|
111
|
+
return CapacitySnapshot(
|
|
112
|
+
provider_id=provider_id,
|
|
113
|
+
status=UsageStatus.UNKNOWN,
|
|
114
|
+
confidence=Confidence.OBSERVED,
|
|
115
|
+
windows=[window],
|
|
116
|
+
warnings=["ccusage reads local Claude Code logs; it is telemetry, not authoritative quota."],
|
|
117
|
+
raw=raw,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _ccusage_command(binary: str | None = None) -> list[str] | None:
|
|
122
|
+
if binary:
|
|
123
|
+
return shlex.split(binary)
|
|
124
|
+
configured = os.environ.get("AGENTPOOL_CCUSAGE_COMMAND")
|
|
125
|
+
if configured:
|
|
126
|
+
return shlex.split(configured)
|
|
127
|
+
executable = shutil.which("ccusage")
|
|
128
|
+
if executable:
|
|
129
|
+
return [executable]
|
|
130
|
+
return None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from agentpool.models import CapacitySnapshot
|
|
6
|
+
from agentpool.usage._common import _tmux_slash_usage_probe, unavailable
|
|
7
|
+
from agentpool.usage.provider_parsers import parse_claude_usage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def claude_code_usage_snapshot(provider_id: str, binary: str | None = None) -> CapacitySnapshot:
|
|
11
|
+
executable = binary or shutil.which("claude")
|
|
12
|
+
if not executable:
|
|
13
|
+
return unavailable(provider_id, "Claude Code is not installed.")
|
|
14
|
+
return _tmux_slash_usage_probe(
|
|
15
|
+
provider_id=provider_id,
|
|
16
|
+
command=[executable, "--allowed-tools", ""],
|
|
17
|
+
slash_command="/usage",
|
|
18
|
+
parser=parse_claude_usage,
|
|
19
|
+
source="claude_pty_usage",
|
|
20
|
+
startup_delay=1.2,
|
|
21
|
+
timeout=18.0,
|
|
22
|
+
extra_keys_after_match=[["PageDown"]],
|
|
23
|
+
)
|
agentpool/usage/codex.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import select
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agentpool.models import CapacitySnapshot, Confidence, UsageWindow, UsageWindowKind
|
|
11
|
+
from agentpool.usage._common import (
|
|
12
|
+
ProbeError,
|
|
13
|
+
_clamp_percent,
|
|
14
|
+
_epoch_seconds,
|
|
15
|
+
_int_number,
|
|
16
|
+
_number,
|
|
17
|
+
_safe_read_pipe,
|
|
18
|
+
_status_from_windows,
|
|
19
|
+
_terminate_process,
|
|
20
|
+
unavailable,
|
|
21
|
+
unknown,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def codex_cli_usage_snapshot(provider_id: str, binary: str | None = None) -> CapacitySnapshot:
|
|
26
|
+
executable = binary or shutil.which("codex")
|
|
27
|
+
if not executable:
|
|
28
|
+
return unavailable(provider_id, "Codex CLI is not installed.")
|
|
29
|
+
try:
|
|
30
|
+
response = _codex_rpc_rate_limits(executable)
|
|
31
|
+
snapshot = parse_codex_rate_limits(provider_id, response)
|
|
32
|
+
snapshot.raw["source"] = "codex_app_server"
|
|
33
|
+
return snapshot
|
|
34
|
+
except ProbeError as exc:
|
|
35
|
+
recovered = _recover_codex_error_snapshot(provider_id, str(exc))
|
|
36
|
+
if recovered:
|
|
37
|
+
recovered.warnings.append("Codex app-server returned usage inside an error response.")
|
|
38
|
+
return recovered
|
|
39
|
+
return unknown(provider_id, f"Codex app-server usage probe failed: {exc}", source="codex_app_server")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_codex_rate_limits(provider_id: str, payload: dict[str, Any]) -> CapacitySnapshot:
|
|
43
|
+
rate_limits = payload.get("rateLimits") if isinstance(payload.get("rateLimits"), dict) else payload
|
|
44
|
+
windows: list[UsageWindow] = []
|
|
45
|
+
for fallback_name, item in (("primary", rate_limits.get("primary")), ("secondary", rate_limits.get("secondary"))):
|
|
46
|
+
if not isinstance(item, dict):
|
|
47
|
+
continue
|
|
48
|
+
used = _number(item.get("usedPercent"))
|
|
49
|
+
if used is None:
|
|
50
|
+
continue
|
|
51
|
+
duration = _int_number(item.get("windowDurationMins"))
|
|
52
|
+
name = _codex_window_name(fallback_name, duration)
|
|
53
|
+
reset_at = _epoch_seconds(item.get("resetsAt"))
|
|
54
|
+
windows.append(
|
|
55
|
+
UsageWindow(
|
|
56
|
+
name=name,
|
|
57
|
+
kind=_codex_window_kind(name),
|
|
58
|
+
used_percent=_clamp_percent(used),
|
|
59
|
+
remaining_percent=_clamp_percent(100.0 - used),
|
|
60
|
+
reset_at=reset_at,
|
|
61
|
+
confidence=Confidence.OFFICIAL,
|
|
62
|
+
raw_text=f"{fallback_name}:{duration or 'unknown'}",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
credits_remaining = None
|
|
66
|
+
credits = rate_limits.get("credits")
|
|
67
|
+
if isinstance(credits, dict) and not credits.get("unlimited"):
|
|
68
|
+
credits_remaining = _number(credits.get("balance"))
|
|
69
|
+
if not windows and credits_remaining is None:
|
|
70
|
+
raise ProbeError("No rate-limit windows or credits in Codex response.")
|
|
71
|
+
return CapacitySnapshot(
|
|
72
|
+
provider_id=provider_id,
|
|
73
|
+
status=_status_from_windows(windows),
|
|
74
|
+
confidence=Confidence.OFFICIAL,
|
|
75
|
+
windows=windows,
|
|
76
|
+
credits_remaining=credits_remaining,
|
|
77
|
+
raw={"credits": _safe_credit_summary(credits) if isinstance(credits, dict) else None},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _codex_rpc_rate_limits(executable: str) -> dict[str, Any]:
|
|
82
|
+
proc = subprocess.Popen(
|
|
83
|
+
[executable, "-s", "read-only", "-a", "untrusted", "app-server"],
|
|
84
|
+
stdin=subprocess.PIPE,
|
|
85
|
+
stdout=subprocess.PIPE,
|
|
86
|
+
stderr=subprocess.PIPE,
|
|
87
|
+
text=True,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
_json_rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "agentpool", "version": "0.1.0"}}, 8.0)
|
|
91
|
+
_json_rpc_notify(proc, "initialized")
|
|
92
|
+
return _json_rpc_request(proc, 2, "account/rateLimits/read", {}, 4.0)
|
|
93
|
+
finally:
|
|
94
|
+
_terminate_process(proc)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _json_rpc_request(
|
|
98
|
+
proc: subprocess.Popen[str],
|
|
99
|
+
request_id: int,
|
|
100
|
+
method: str,
|
|
101
|
+
params: dict[str, Any],
|
|
102
|
+
timeout: float,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
_write_json_line(proc, {"id": request_id, "method": method, "params": params})
|
|
105
|
+
deadline = time.monotonic() + timeout
|
|
106
|
+
while time.monotonic() < deadline:
|
|
107
|
+
if proc.poll() is not None:
|
|
108
|
+
stderr = _safe_read_pipe(proc.stderr)
|
|
109
|
+
raise ProbeError(f"codex app-server exited during {method}: {stderr}".strip())
|
|
110
|
+
remaining = max(0.05, deadline - time.monotonic())
|
|
111
|
+
assert proc.stdout is not None
|
|
112
|
+
ready, _, _ = select.select([proc.stdout], [], [], remaining)
|
|
113
|
+
if not ready:
|
|
114
|
+
continue
|
|
115
|
+
line = proc.stdout.readline()
|
|
116
|
+
if not line:
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
message = json.loads(line)
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
continue
|
|
122
|
+
if message.get("id") != request_id:
|
|
123
|
+
continue
|
|
124
|
+
if "error" in message:
|
|
125
|
+
error = message["error"]
|
|
126
|
+
raise ProbeError(error.get("message", str(error)) if isinstance(error, dict) else str(error))
|
|
127
|
+
result = message.get("result")
|
|
128
|
+
if not isinstance(result, dict):
|
|
129
|
+
raise ProbeError(f"JSON-RPC {method} returned no object result.")
|
|
130
|
+
return result
|
|
131
|
+
_terminate_process(proc)
|
|
132
|
+
raise ProbeError(f"codex app-server timed out during {method}.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _json_rpc_notify(proc: subprocess.Popen[str], method: str) -> None:
|
|
136
|
+
_write_json_line(proc, {"method": method, "params": {}})
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _write_json_line(proc: subprocess.Popen[str], payload: dict[str, Any]) -> None:
|
|
140
|
+
if proc.stdin is None:
|
|
141
|
+
raise ProbeError("process stdin is unavailable")
|
|
142
|
+
proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
143
|
+
proc.stdin.flush()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _recover_codex_error_snapshot(provider_id: str, message: str) -> CapacitySnapshot | None:
|
|
147
|
+
start = message.find("{")
|
|
148
|
+
end = message.rfind("}")
|
|
149
|
+
if start < 0 or end <= start:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
payload = json.loads(message[start : end + 1])
|
|
153
|
+
except json.JSONDecodeError:
|
|
154
|
+
return None
|
|
155
|
+
rate_limit = payload.get("rate_limit")
|
|
156
|
+
if not isinstance(rate_limit, dict):
|
|
157
|
+
return None
|
|
158
|
+
windows = []
|
|
159
|
+
details = rate_limit.get("details") if isinstance(rate_limit.get("details"), list) else []
|
|
160
|
+
for item in details:
|
|
161
|
+
if not isinstance(item, dict):
|
|
162
|
+
continue
|
|
163
|
+
used = _number(item.get("used_percent"))
|
|
164
|
+
seconds = _int_number(item.get("limit_window_seconds"))
|
|
165
|
+
reset = _epoch_seconds(item.get("reset_at"))
|
|
166
|
+
if used is None:
|
|
167
|
+
continue
|
|
168
|
+
windows.append(
|
|
169
|
+
UsageWindow(
|
|
170
|
+
name=_codex_window_name("window", int(seconds / 60) if seconds else None),
|
|
171
|
+
kind=_codex_window_kind(_codex_window_name("window", int(seconds / 60) if seconds else None)),
|
|
172
|
+
used_percent=_clamp_percent(used),
|
|
173
|
+
remaining_percent=_clamp_percent(100.0 - used),
|
|
174
|
+
reset_at=reset,
|
|
175
|
+
confidence=Confidence.OFFICIAL,
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
if not windows:
|
|
179
|
+
return None
|
|
180
|
+
return CapacitySnapshot(
|
|
181
|
+
provider_id=provider_id,
|
|
182
|
+
status=_status_from_windows(windows),
|
|
183
|
+
confidence=Confidence.OFFICIAL,
|
|
184
|
+
windows=windows,
|
|
185
|
+
raw={"source": "codex_app_server_error_body"},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _codex_window_name(fallback: str, duration_mins: int | None) -> str:
|
|
190
|
+
if duration_mins == 300:
|
|
191
|
+
return "5h"
|
|
192
|
+
if duration_mins == 10080:
|
|
193
|
+
return "weekly"
|
|
194
|
+
return fallback
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _codex_window_kind(name: str) -> UsageWindowKind:
|
|
198
|
+
if name == "5h":
|
|
199
|
+
return UsageWindowKind.FIVE_HOUR
|
|
200
|
+
if name == "weekly":
|
|
201
|
+
return UsageWindowKind.WEEKLY
|
|
202
|
+
return UsageWindowKind.UNKNOWN
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _safe_credit_summary(credits: dict[str, Any]) -> dict[str, Any]:
|
|
206
|
+
return {
|
|
207
|
+
"hasCredits": bool(credits.get("hasCredits")),
|
|
208
|
+
"unlimited": bool(credits.get("unlimited")),
|
|
209
|
+
"hasBalance": credits.get("balance") is not None,
|
|
210
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agentpool.models import CapacitySnapshot, Confidence, UsageWindow, UsageWindowKind
|
|
8
|
+
from agentpool.usage._common import (
|
|
9
|
+
ProbeError,
|
|
10
|
+
_clamp_percent,
|
|
11
|
+
_duration_window_kind,
|
|
12
|
+
_extract_json_payload,
|
|
13
|
+
_int_number,
|
|
14
|
+
_number,
|
|
15
|
+
_parse_datetime,
|
|
16
|
+
_status_from_windows,
|
|
17
|
+
_clean_optional_string,
|
|
18
|
+
unavailable,
|
|
19
|
+
unknown,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
CODEXBAR_PROVIDER_MAP = {
|
|
23
|
+
"claude-code": "claude",
|
|
24
|
+
"codex-cli": "codex",
|
|
25
|
+
"copilot-cli": "copilot",
|
|
26
|
+
"cursor-cli": "cursor",
|
|
27
|
+
"opencode": "opencode",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
CODEXBAR_SAFE_SOURCE_MAP = {
|
|
31
|
+
"claude-code": "cli",
|
|
32
|
+
"codex-cli": "cli",
|
|
33
|
+
"copilot-cli": "api",
|
|
34
|
+
"cursor-cli": "cli",
|
|
35
|
+
"opencode": "api",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_codexbar(binary: str | None = None) -> dict[str, Any]:
|
|
40
|
+
executable = binary or shutil.which("codexbar")
|
|
41
|
+
if not executable:
|
|
42
|
+
return {
|
|
43
|
+
"installed": False,
|
|
44
|
+
"path": None,
|
|
45
|
+
"version": None,
|
|
46
|
+
"supported_agentpool_providers": sorted(CODEXBAR_PROVIDER_MAP),
|
|
47
|
+
"safe_sources": CODEXBAR_SAFE_SOURCE_MAP,
|
|
48
|
+
}
|
|
49
|
+
version = None
|
|
50
|
+
try:
|
|
51
|
+
proc = subprocess.run([executable, "--version"], capture_output=True, text=True, timeout=3, check=False)
|
|
52
|
+
if proc.returncode == 0:
|
|
53
|
+
version = (proc.stdout or proc.stderr).strip().splitlines()[0][:200]
|
|
54
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
55
|
+
version = None
|
|
56
|
+
return {
|
|
57
|
+
"installed": True,
|
|
58
|
+
"path": executable,
|
|
59
|
+
"version": version,
|
|
60
|
+
"supported_agentpool_providers": sorted(CODEXBAR_PROVIDER_MAP),
|
|
61
|
+
"safe_sources": CODEXBAR_SAFE_SOURCE_MAP,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def codexbar_usage_snapshot(
|
|
66
|
+
provider_id: str,
|
|
67
|
+
binary: str | None = None,
|
|
68
|
+
source: str | None = None,
|
|
69
|
+
) -> CapacitySnapshot:
|
|
70
|
+
executable = binary or shutil.which("codexbar")
|
|
71
|
+
if not executable:
|
|
72
|
+
return unavailable(provider_id, "CodexBar CLI is not installed.")
|
|
73
|
+
codexbar_provider = CODEXBAR_PROVIDER_MAP.get(provider_id)
|
|
74
|
+
if not codexbar_provider:
|
|
75
|
+
return unknown(
|
|
76
|
+
provider_id,
|
|
77
|
+
f"CodexBar does not expose a safe mapped usage provider for {provider_id}.",
|
|
78
|
+
source="codexbar",
|
|
79
|
+
)
|
|
80
|
+
safe_source = source or CODEXBAR_SAFE_SOURCE_MAP.get(provider_id)
|
|
81
|
+
if not safe_source:
|
|
82
|
+
return unknown(provider_id, f"No safe CodexBar source is configured for {provider_id}.", source="codexbar")
|
|
83
|
+
command = [
|
|
84
|
+
executable,
|
|
85
|
+
"usage",
|
|
86
|
+
"--provider",
|
|
87
|
+
codexbar_provider,
|
|
88
|
+
"--source",
|
|
89
|
+
safe_source,
|
|
90
|
+
"--format",
|
|
91
|
+
"json",
|
|
92
|
+
"--json-only",
|
|
93
|
+
"--no-color",
|
|
94
|
+
]
|
|
95
|
+
try:
|
|
96
|
+
proc = subprocess.run(command, capture_output=True, text=True, timeout=45, check=False)
|
|
97
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
98
|
+
return unknown(provider_id, f"CodexBar usage probe failed: {exc}", source="codexbar")
|
|
99
|
+
text = "\n".join(part for part in [proc.stdout, proc.stderr] if part)
|
|
100
|
+
try:
|
|
101
|
+
payload = _extract_json_payload(text)
|
|
102
|
+
snapshot = parse_codexbar_usage(provider_id, payload, expected_provider=codexbar_provider)
|
|
103
|
+
snapshot.raw["source"] = "codexbar"
|
|
104
|
+
snapshot.raw["codexbar_provider"] = codexbar_provider
|
|
105
|
+
snapshot.raw["codexbar_source"] = safe_source
|
|
106
|
+
if proc.returncode != 0:
|
|
107
|
+
snapshot.warnings.append(f"CodexBar exited with status {proc.returncode}.")
|
|
108
|
+
return snapshot
|
|
109
|
+
except ProbeError as exc:
|
|
110
|
+
return unknown(provider_id, f"CodexBar usage probe failed: {exc}", source="codexbar")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_codexbar_usage(
|
|
114
|
+
provider_id: str,
|
|
115
|
+
payload: Any,
|
|
116
|
+
expected_provider: str | None = None,
|
|
117
|
+
) -> CapacitySnapshot:
|
|
118
|
+
entries = payload if isinstance(payload, list) else [payload]
|
|
119
|
+
entry = None
|
|
120
|
+
for item in entries:
|
|
121
|
+
if not isinstance(item, dict):
|
|
122
|
+
continue
|
|
123
|
+
if expected_provider is None or item.get("provider") == expected_provider:
|
|
124
|
+
entry = item
|
|
125
|
+
break
|
|
126
|
+
if entry is None:
|
|
127
|
+
raise ProbeError("CodexBar output did not include the requested provider.")
|
|
128
|
+
error = entry.get("error")
|
|
129
|
+
if isinstance(error, dict):
|
|
130
|
+
raise ProbeError(_clean_optional_string(error.get("message")) or str(error))
|
|
131
|
+
usage = entry.get("usage")
|
|
132
|
+
if not isinstance(usage, dict):
|
|
133
|
+
raise ProbeError("CodexBar output did not include a usage object.")
|
|
134
|
+
windows = []
|
|
135
|
+
for name in ("primary", "secondary", "tertiary"):
|
|
136
|
+
item = usage.get(name)
|
|
137
|
+
if not isinstance(item, dict):
|
|
138
|
+
continue
|
|
139
|
+
used = _number(item.get("usedPercent"))
|
|
140
|
+
remaining = _number(item.get("remainingPercent"))
|
|
141
|
+
if used is None and remaining is None:
|
|
142
|
+
continue
|
|
143
|
+
if remaining is None and used is not None:
|
|
144
|
+
remaining = 100.0 - used
|
|
145
|
+
if used is None and remaining is not None:
|
|
146
|
+
used = 100.0 - remaining
|
|
147
|
+
duration = _int_number(item.get("windowMinutes"))
|
|
148
|
+
windows.append(
|
|
149
|
+
UsageWindow(
|
|
150
|
+
name=_codexbar_window_name(name, duration),
|
|
151
|
+
kind=_duration_window_kind(duration),
|
|
152
|
+
status=name,
|
|
153
|
+
used_percent=_clamp_percent(used) if used is not None else None,
|
|
154
|
+
remaining_percent=_clamp_percent(remaining) if remaining is not None else None,
|
|
155
|
+
reset_at=_parse_datetime(item.get("resetsAt")),
|
|
156
|
+
confidence=Confidence.LOCAL_CLI,
|
|
157
|
+
raw_text=f"codexbar:{name}:{duration or 'unknown'}",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
credits_remaining = None
|
|
161
|
+
credits = entry.get("credits")
|
|
162
|
+
if isinstance(credits, dict):
|
|
163
|
+
credits_remaining = _number(credits.get("remaining"))
|
|
164
|
+
if not windows and credits_remaining is None:
|
|
165
|
+
raise ProbeError("CodexBar output did not include parseable windows or credits.")
|
|
166
|
+
raw: dict[str, Any] = {
|
|
167
|
+
"codexbar_source": entry.get("source"),
|
|
168
|
+
"version": entry.get("version"),
|
|
169
|
+
"login_method": _clean_optional_string(usage.get("loginMethod")),
|
|
170
|
+
"account_email": _clean_optional_string(usage.get("accountEmail")),
|
|
171
|
+
}
|
|
172
|
+
return CapacitySnapshot(
|
|
173
|
+
provider_id=provider_id,
|
|
174
|
+
status=_status_from_windows(windows),
|
|
175
|
+
confidence=Confidence.LOCAL_CLI,
|
|
176
|
+
windows=windows,
|
|
177
|
+
credits_remaining=credits_remaining,
|
|
178
|
+
raw={key: value for key, value in raw.items() if value is not None},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _codexbar_window_name(fallback: str, duration_mins: int | None) -> str:
|
|
183
|
+
kind = _duration_window_kind(duration_mins)
|
|
184
|
+
if kind != UsageWindowKind.UNKNOWN:
|
|
185
|
+
return kind.value
|
|
186
|
+
return fallback
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from agentpool.models import CapacitySnapshot, UsageStatus, UsageWindow
|
|
6
|
+
from agentpool.usage._common import _status_from_windows
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def combine_usage_snapshots(
|
|
10
|
+
native: CapacitySnapshot,
|
|
11
|
+
codexbar: CapacitySnapshot,
|
|
12
|
+
ccusage: CapacitySnapshot | None = None,
|
|
13
|
+
) -> CapacitySnapshot:
|
|
14
|
+
if codexbar.status in {UsageStatus.UNKNOWN, UsageStatus.UNAVAILABLE, UsageStatus.UNAUTHENTICATED}:
|
|
15
|
+
snapshot = native.model_copy(deep=True)
|
|
16
|
+
snapshot.raw["alternate_sources"] = [_usage_source_summary(codexbar)]
|
|
17
|
+
if codexbar.warnings:
|
|
18
|
+
snapshot.warnings.extend(f"CodexBar: {warning}" for warning in codexbar.warnings)
|
|
19
|
+
return _add_ccusage_enrichment(snapshot, ccusage)
|
|
20
|
+
if native.status in {UsageStatus.UNKNOWN, UsageStatus.UNAVAILABLE, UsageStatus.UNAUTHENTICATED}:
|
|
21
|
+
snapshot = codexbar.model_copy(deep=True)
|
|
22
|
+
snapshot.raw["source"] = "combined"
|
|
23
|
+
snapshot.raw["primary_source"] = "codexbar"
|
|
24
|
+
snapshot.raw["alternate_sources"] = [_usage_source_summary(native)]
|
|
25
|
+
if native.warnings:
|
|
26
|
+
snapshot.warnings.extend(f"Native probe: {warning}" for warning in native.warnings)
|
|
27
|
+
return _add_ccusage_enrichment(snapshot, ccusage)
|
|
28
|
+
snapshot = native.model_copy(deep=True)
|
|
29
|
+
existing = {_usage_window_key(window) for window in snapshot.windows}
|
|
30
|
+
for window in codexbar.windows:
|
|
31
|
+
key = _usage_window_key(window)
|
|
32
|
+
if key not in existing:
|
|
33
|
+
snapshot.windows.append(window)
|
|
34
|
+
existing.add(key)
|
|
35
|
+
if snapshot.credits_remaining is None and codexbar.credits_remaining is not None:
|
|
36
|
+
snapshot.credits_remaining = codexbar.credits_remaining
|
|
37
|
+
snapshot.status = _status_from_windows(snapshot.windows)
|
|
38
|
+
snapshot.raw["source"] = "combined"
|
|
39
|
+
snapshot.raw["primary_source"] = native.raw.get("source", "native")
|
|
40
|
+
snapshot.raw["alternate_sources"] = [_usage_source_summary(codexbar)]
|
|
41
|
+
return _add_ccusage_enrichment(snapshot, ccusage)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _add_ccusage_enrichment(
|
|
45
|
+
snapshot: CapacitySnapshot,
|
|
46
|
+
ccusage: CapacitySnapshot | None = None,
|
|
47
|
+
) -> CapacitySnapshot:
|
|
48
|
+
if ccusage is None:
|
|
49
|
+
return snapshot
|
|
50
|
+
enriched = snapshot.model_copy(deep=True)
|
|
51
|
+
enriched.raw.setdefault("alternate_sources", [])
|
|
52
|
+
enriched.raw["alternate_sources"].append(_usage_source_summary(ccusage))
|
|
53
|
+
if ccusage.status in {UsageStatus.UNAVAILABLE, UsageStatus.UNAUTHENTICATED}:
|
|
54
|
+
return enriched
|
|
55
|
+
if ccusage.warnings:
|
|
56
|
+
enriched.warnings.extend(f"ccusage: {warning}" for warning in ccusage.warnings)
|
|
57
|
+
enriched.raw["ccusage"] = ccusage.raw
|
|
58
|
+
return enriched
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _usage_window_key(window: UsageWindow) -> tuple[str, str, str | None]:
|
|
62
|
+
return (window.kind.value, window.name, window.reset_at.isoformat() if window.reset_at else None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _usage_source_summary(snapshot: CapacitySnapshot) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"source": snapshot.raw.get("source"),
|
|
68
|
+
"status": snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status),
|
|
69
|
+
"confidence": snapshot.confidence.value if hasattr(snapshot.confidence, "value") else str(snapshot.confidence),
|
|
70
|
+
"windows": len(snapshot.windows),
|
|
71
|
+
}
|