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,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
from agentpool.models import CapacitySnapshot, Confidence, UsageStatus, UsageWindow, UsageWindowKind
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_codex_status(provider_id: str, text: str) -> CapacitySnapshot | None:
|
|
10
|
+
windows: list[UsageWindow] = []
|
|
11
|
+
for name, percent, reset in re.findall(
|
|
12
|
+
r"(?P<name>5h|Weekly)\s+limit:\s+\[[^\]]+\]\s+(?P<percent>\d+(?:\.\d+)?)%\s+left\s+\(resets\s+(?P<reset>[^)]+)\)",
|
|
13
|
+
text,
|
|
14
|
+
re.I,
|
|
15
|
+
):
|
|
16
|
+
windows.append(
|
|
17
|
+
UsageWindow(
|
|
18
|
+
name=name.lower(),
|
|
19
|
+
kind=UsageWindowKind.FIVE_HOUR if name.lower() == "5h" else UsageWindowKind.WEEKLY,
|
|
20
|
+
remaining_percent=float(percent),
|
|
21
|
+
used_percent=100.0 - float(percent),
|
|
22
|
+
confidence=Confidence.LOCAL_CLI,
|
|
23
|
+
raw_text=f"{name} limit resets {reset}",
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
footer = re.search(
|
|
27
|
+
r"\b5h\s+(?P<hour>\d+(?:\.\d+)?)%\s+.*?\bweekly\s+(?P<weekly>\d+(?:\.\d+)?)%",
|
|
28
|
+
text,
|
|
29
|
+
re.I | re.S,
|
|
30
|
+
)
|
|
31
|
+
if footer and not windows:
|
|
32
|
+
windows.extend(
|
|
33
|
+
[
|
|
34
|
+
UsageWindow(
|
|
35
|
+
name="5h",
|
|
36
|
+
kind=UsageWindowKind.FIVE_HOUR,
|
|
37
|
+
remaining_percent=float(footer.group("hour")),
|
|
38
|
+
used_percent=100.0 - float(footer.group("hour")),
|
|
39
|
+
confidence=Confidence.LOCAL_CLI,
|
|
40
|
+
raw_text=footer.group(0),
|
|
41
|
+
),
|
|
42
|
+
UsageWindow(
|
|
43
|
+
name="weekly",
|
|
44
|
+
kind=UsageWindowKind.WEEKLY,
|
|
45
|
+
remaining_percent=float(footer.group("weekly")),
|
|
46
|
+
used_percent=100.0 - float(footer.group("weekly")),
|
|
47
|
+
confidence=Confidence.LOCAL_CLI,
|
|
48
|
+
raw_text=footer.group(0),
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
if not windows:
|
|
53
|
+
return None
|
|
54
|
+
status = _status_from_remaining(min(w.remaining_percent or 100.0 for w in windows))
|
|
55
|
+
return CapacitySnapshot(provider_id=provider_id, status=status, confidence=Confidence.LOCAL_CLI, windows=windows)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_claude_usage(provider_id: str, text: str) -> CapacitySnapshot | None:
|
|
60
|
+
clean = _strip_ansi(text)
|
|
61
|
+
if re.search(r"failed to load usage|token expired|not authenticated|login required", clean, re.I):
|
|
62
|
+
return CapacitySnapshot(
|
|
63
|
+
provider_id=provider_id,
|
|
64
|
+
status=UsageStatus.UNAUTHENTICATED,
|
|
65
|
+
confidence=Confidence.LOCAL_CLI,
|
|
66
|
+
warnings=["Claude CLI reported that usage could not be loaded."],
|
|
67
|
+
raw={"source": "claude_cli_usage_error"},
|
|
68
|
+
)
|
|
69
|
+
windows: list[UsageWindow] = []
|
|
70
|
+
for label, name in (
|
|
71
|
+
("Current session", "session"),
|
|
72
|
+
("Current week (all models)", "weekly"),
|
|
73
|
+
("Current week (Opus)", "weekly_opus"),
|
|
74
|
+
("Current week (Sonnet only)", "weekly_sonnet"),
|
|
75
|
+
("Current week (Sonnet)", "weekly_sonnet"),
|
|
76
|
+
("Current week", "weekly"),
|
|
77
|
+
):
|
|
78
|
+
window = _extract_labeled_percent(clean, label, name)
|
|
79
|
+
if window and all(existing.name != window.name for existing in windows):
|
|
80
|
+
windows.append(window)
|
|
81
|
+
if not windows:
|
|
82
|
+
return None
|
|
83
|
+
credits_remaining = None
|
|
84
|
+
extra = re.search(
|
|
85
|
+
r"Extra usage(?P<context>.{0,260}?\$(?P<spent>\d+(?:\.\d+)?)\s*/\s*\$(?P<limit>\d+(?:\.\d+)?)\s+spent)",
|
|
86
|
+
clean,
|
|
87
|
+
re.I | re.S,
|
|
88
|
+
)
|
|
89
|
+
if extra:
|
|
90
|
+
spent = float(extra.group("spent"))
|
|
91
|
+
limit = float(extra.group("limit"))
|
|
92
|
+
remaining = max(0.0, limit - spent)
|
|
93
|
+
credits_remaining = round(remaining, 2)
|
|
94
|
+
used_percent = (spent / limit) * 100 if limit > 0 else 100.0
|
|
95
|
+
windows.append(
|
|
96
|
+
UsageWindow(
|
|
97
|
+
name="extra_usage",
|
|
98
|
+
kind=UsageWindowKind.ON_DEMAND,
|
|
99
|
+
used_percent=max(0.0, min(100.0, used_percent)),
|
|
100
|
+
remaining_percent=max(0.0, min(100.0, 100.0 - used_percent)),
|
|
101
|
+
used_units=spent,
|
|
102
|
+
remaining_units=credits_remaining,
|
|
103
|
+
confidence=Confidence.LOCAL_CLI,
|
|
104
|
+
raw_text=extra.group("context").strip(),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
status = _status_from_remaining(min(w.remaining_percent or 100.0 for w in windows))
|
|
108
|
+
return CapacitySnapshot(
|
|
109
|
+
provider_id=provider_id,
|
|
110
|
+
status=status,
|
|
111
|
+
confidence=Confidence.LOCAL_CLI,
|
|
112
|
+
windows=windows,
|
|
113
|
+
credits_remaining=credits_remaining,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_devin_usage(provider_id: str, text: str) -> CapacitySnapshot | None:
|
|
118
|
+
match = re.search(r"Quota used:\s+(?P<used>\d+(?:\.\d+)?)%\s+\(remaining:\s+(?P<remaining>\d+(?:\.\d+)?)%\)", text, re.I)
|
|
119
|
+
if not match:
|
|
120
|
+
banner = re.search(r"\b(?P<remaining>\d+(?:\.\d+)?)%\s+remaining\s+\(resets\s+in\s+(?P<reset>[^)]+)\)", text, re.I)
|
|
121
|
+
if not banner:
|
|
122
|
+
return None
|
|
123
|
+
remaining = float(banner.group("remaining"))
|
|
124
|
+
used = 100.0 - remaining
|
|
125
|
+
raw = banner.group(0)
|
|
126
|
+
else:
|
|
127
|
+
used = float(match.group("used"))
|
|
128
|
+
remaining = float(match.group("remaining"))
|
|
129
|
+
raw = match.group(0)
|
|
130
|
+
credits = None
|
|
131
|
+
credits_match = re.search(r"Extra usage balance:\s+\$(?P<credits>\d+(?:\.\d+)?)", text, re.I)
|
|
132
|
+
if credits_match:
|
|
133
|
+
credits = float(credits_match.group("credits"))
|
|
134
|
+
reset_at = _parse_devin_reset(text)
|
|
135
|
+
return CapacitySnapshot(
|
|
136
|
+
provider_id=provider_id,
|
|
137
|
+
status=_status_from_remaining(remaining),
|
|
138
|
+
confidence=Confidence.LOCAL_CLI,
|
|
139
|
+
credits_remaining=credits,
|
|
140
|
+
warnings=["Devin CLI /usage exposes the weekly included quota; daily quota requires the plan-status API."],
|
|
141
|
+
windows=[
|
|
142
|
+
UsageWindow(
|
|
143
|
+
name="weekly",
|
|
144
|
+
kind=UsageWindowKind.WEEKLY,
|
|
145
|
+
status="weekly",
|
|
146
|
+
used_percent=used,
|
|
147
|
+
remaining_percent=remaining,
|
|
148
|
+
reset_at=reset_at,
|
|
149
|
+
confidence=Confidence.LOCAL_CLI,
|
|
150
|
+
raw_text=raw,
|
|
151
|
+
)
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_droid_status(provider_id: str, text: str) -> CapacitySnapshot | None:
|
|
157
|
+
if "Credit Usage (Current Session)" not in text and "Session Token Usage" not in text:
|
|
158
|
+
return None
|
|
159
|
+
raw: dict[str, float] = {}
|
|
160
|
+
for key, value in re.findall(r"(Input|Output|Cache Creation|Cache Read):\s+([\d.]+)\s+(?:credits|tokens)", text, re.I):
|
|
161
|
+
raw[key.lower().replace(" ", "_")] = float(value)
|
|
162
|
+
return CapacitySnapshot(
|
|
163
|
+
provider_id=provider_id,
|
|
164
|
+
status=UsageStatus.UNKNOWN,
|
|
165
|
+
confidence=Confidence.LOCAL_CLI,
|
|
166
|
+
warnings=["Droid CLI status exposes current-session usage, not subscription quota."],
|
|
167
|
+
raw={"current_session": raw},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_opencode_stats(provider_id: str, text: str) -> CapacitySnapshot | None:
|
|
172
|
+
if "COST & TOKENS" not in text and "MODEL USAGE" not in text:
|
|
173
|
+
return None
|
|
174
|
+
raw: dict[str, float | int] = {}
|
|
175
|
+
money = re.search(r"Total Cost\s+\$(?P<cost>\d+(?:\.\d+)?)", text, re.I)
|
|
176
|
+
messages = re.search(r"Messages\s+(?P<messages>[\d,]+)", text, re.I)
|
|
177
|
+
sessions = re.search(r"Sessions\s+(?P<sessions>[\d,]+)", text, re.I)
|
|
178
|
+
if money:
|
|
179
|
+
raw["total_cost"] = float(money.group("cost"))
|
|
180
|
+
if messages:
|
|
181
|
+
raw["messages"] = int(messages.group("messages").replace(",", ""))
|
|
182
|
+
if sessions:
|
|
183
|
+
raw["sessions"] = int(sessions.group("sessions").replace(",", ""))
|
|
184
|
+
return CapacitySnapshot(
|
|
185
|
+
provider_id=provider_id,
|
|
186
|
+
status=UsageStatus.UNKNOWN,
|
|
187
|
+
confidence=Confidence.LOCAL_CLI,
|
|
188
|
+
warnings=["OpenCode stats expose local token/cost history, not subscription quota."],
|
|
189
|
+
raw=raw,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _status_from_remaining(remaining: float) -> UsageStatus:
|
|
194
|
+
if remaining <= 0:
|
|
195
|
+
return UsageStatus.LIMIT_REACHED
|
|
196
|
+
if remaining <= 15:
|
|
197
|
+
return UsageStatus.NEAR_LIMIT
|
|
198
|
+
return UsageStatus.AVAILABLE
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _strip_ansi(text: str) -> str:
|
|
202
|
+
return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _parse_devin_reset(text: str) -> datetime | None:
|
|
206
|
+
match = re.search(
|
|
207
|
+
r"Quota resets\s+(?P<month>[A-Z][a-z]+)\s+(?P<day>\d{1,2}),\s+(?P<time>\d{1,2}:\d{2}\s+[AP]M)\s+\(UTC(?P<offset>[+-]\d{1,2})\)",
|
|
208
|
+
text,
|
|
209
|
+
)
|
|
210
|
+
if not match:
|
|
211
|
+
return None
|
|
212
|
+
year = datetime.now().year
|
|
213
|
+
offset = timezone(timedelta(hours=int(match.group("offset"))))
|
|
214
|
+
try:
|
|
215
|
+
parsed = datetime.strptime(
|
|
216
|
+
f"{match.group('month')} {match.group('day')} {year} {match.group('time')}",
|
|
217
|
+
"%B %d %Y %I:%M %p",
|
|
218
|
+
)
|
|
219
|
+
except ValueError:
|
|
220
|
+
return None
|
|
221
|
+
reset_at = parsed.replace(tzinfo=offset)
|
|
222
|
+
if reset_at < datetime.now(offset) - timedelta(days=180):
|
|
223
|
+
reset_at = reset_at.replace(year=year + 1)
|
|
224
|
+
return reset_at
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _extract_labeled_percent(text: str, label: str, name: str) -> UsageWindow | None:
|
|
228
|
+
pattern = re.compile(
|
|
229
|
+
rf"{re.escape(label)}(?P<context>.{{0,800}}?)(?=(?:Current session|Current week|\Z))",
|
|
230
|
+
re.I | re.S,
|
|
231
|
+
)
|
|
232
|
+
match = pattern.search(text)
|
|
233
|
+
if not match:
|
|
234
|
+
return None
|
|
235
|
+
context = match.group("context")
|
|
236
|
+
percent_match = re.search(
|
|
237
|
+
r"(?P<percent>\d+(?:\.\d+)?)%\s*(?P<direction>left|remaining|used)?",
|
|
238
|
+
context,
|
|
239
|
+
re.I,
|
|
240
|
+
)
|
|
241
|
+
if not percent_match:
|
|
242
|
+
return None
|
|
243
|
+
percent = float(percent_match.group("percent"))
|
|
244
|
+
direction = (percent_match.group("direction") or "remaining").lower()
|
|
245
|
+
if direction == "used":
|
|
246
|
+
used = percent
|
|
247
|
+
remaining = 100.0 - percent
|
|
248
|
+
else:
|
|
249
|
+
remaining = percent
|
|
250
|
+
used = 100.0 - percent
|
|
251
|
+
reset_match = re.search(r"resets?\s+(?:in\s+)?(?P<reset>[^\n\r|·]+)", context, re.I)
|
|
252
|
+
raw = f"{label}{context[:400]}"
|
|
253
|
+
if reset_match:
|
|
254
|
+
raw = f"{raw} reset {reset_match.group('reset').strip()}"
|
|
255
|
+
return UsageWindow(
|
|
256
|
+
name=name,
|
|
257
|
+
kind=_kind_from_name(name),
|
|
258
|
+
used_percent=max(0.0, min(100.0, used)),
|
|
259
|
+
remaining_percent=max(0.0, min(100.0, remaining)),
|
|
260
|
+
confidence=Confidence.LOCAL_CLI,
|
|
261
|
+
raw_text=raw.strip(),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _kind_from_name(name: str) -> UsageWindowKind:
|
|
266
|
+
if name == "session":
|
|
267
|
+
return UsageWindowKind.SESSION
|
|
268
|
+
if name == "weekly" or name.startswith("weekly_"):
|
|
269
|
+
return UsageWindowKind.WEEKLY
|
|
270
|
+
if name == "daily":
|
|
271
|
+
return UsageWindowKind.DAILY
|
|
272
|
+
if name == "5h":
|
|
273
|
+
return UsageWindowKind.FIVE_HOUR
|
|
274
|
+
if name in {"extra_usage", "on_demand"}:
|
|
275
|
+
return UsageWindowKind.ON_DEMAND
|
|
276
|
+
return UsageWindowKind.UNKNOWN
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentpool.models import CapacitySnapshot, Confidence, ProviderDescriptor, UsageStatus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_usage_summary(
|
|
10
|
+
snapshots: list[CapacitySnapshot],
|
|
11
|
+
min_remaining_percent: int = 10,
|
|
12
|
+
stale_after_seconds: int = 1800,
|
|
13
|
+
provider_descriptors: list[ProviderDescriptor] | None = None,
|
|
14
|
+
) -> dict[str, Any]:
|
|
15
|
+
now = datetime.now(timezone.utc)
|
|
16
|
+
descriptor_by_id = {descriptor.id: descriptor for descriptor in provider_descriptors or []}
|
|
17
|
+
rows = {
|
|
18
|
+
snapshot.provider_id: _provider_summary(
|
|
19
|
+
snapshot,
|
|
20
|
+
min_remaining_percent=min_remaining_percent,
|
|
21
|
+
stale_after_seconds=stale_after_seconds,
|
|
22
|
+
now=now,
|
|
23
|
+
descriptor=descriptor_by_id.get(snapshot.provider_id),
|
|
24
|
+
)
|
|
25
|
+
for snapshot in sorted(snapshots, key=lambda item: item.provider_id)
|
|
26
|
+
}
|
|
27
|
+
next_provider, next_at = _next_available(rows)
|
|
28
|
+
return {
|
|
29
|
+
"checked_at": datetime.now(timezone.utc).isoformat(),
|
|
30
|
+
"providers": rows,
|
|
31
|
+
"min_remaining_percent": min_remaining_percent,
|
|
32
|
+
"next_available_provider": next_provider,
|
|
33
|
+
"next_available_at": next_at,
|
|
34
|
+
"counts": {
|
|
35
|
+
"total": len(rows),
|
|
36
|
+
"usable": sum(1 for row in rows.values() if row["usable"]),
|
|
37
|
+
"unusable": sum(1 for row in rows.values() if not row["usable"]),
|
|
38
|
+
"available": sum(1 for row in rows.values() if row["status"] == UsageStatus.AVAILABLE.value),
|
|
39
|
+
"near_limit": sum(1 for row in rows.values() if row["status"] == UsageStatus.NEAR_LIMIT.value),
|
|
40
|
+
"limit_reached": sum(1 for row in rows.values() if row["status"] == UsageStatus.LIMIT_REACHED.value),
|
|
41
|
+
"unknown": sum(1 for row in rows.values() if row["status"] == UsageStatus.UNKNOWN.value),
|
|
42
|
+
"unavailable": sum(1 for row in rows.values() if row["status"] == UsageStatus.UNAVAILABLE.value),
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _provider_summary(
|
|
48
|
+
snapshot: CapacitySnapshot,
|
|
49
|
+
min_remaining_percent: int,
|
|
50
|
+
stale_after_seconds: int,
|
|
51
|
+
now: datetime,
|
|
52
|
+
descriptor: ProviderDescriptor | None = None,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
windows = [
|
|
55
|
+
{
|
|
56
|
+
"name": window.name,
|
|
57
|
+
"kind": window.kind.value if hasattr(window.kind, "value") else str(window.kind),
|
|
58
|
+
"remaining_percent": window.remaining_percent,
|
|
59
|
+
"used_percent": window.used_percent,
|
|
60
|
+
"remaining_units": window.remaining_units,
|
|
61
|
+
"reset_at": window.reset_at.isoformat() if window.reset_at else None,
|
|
62
|
+
"confidence": window.confidence.value if hasattr(window.confidence, "value") else str(window.confidence),
|
|
63
|
+
}
|
|
64
|
+
for window in snapshot.windows
|
|
65
|
+
]
|
|
66
|
+
age_seconds = max(0.0, (now - snapshot.checked_at).total_seconds())
|
|
67
|
+
stale = age_seconds > stale_after_seconds
|
|
68
|
+
usable, unusable_reason = _usable_reason(snapshot, windows, min_remaining_percent, descriptor, stale)
|
|
69
|
+
return {
|
|
70
|
+
"provider_id": snapshot.provider_id,
|
|
71
|
+
"status": snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status),
|
|
72
|
+
"confidence": snapshot.confidence.value if hasattr(snapshot.confidence, "value") else str(snapshot.confidence),
|
|
73
|
+
"installed": descriptor.installed if descriptor else None,
|
|
74
|
+
"auth_status": descriptor.auth.status if descriptor else None,
|
|
75
|
+
"usable": usable,
|
|
76
|
+
"unusable_reason": unusable_reason,
|
|
77
|
+
"stale": stale,
|
|
78
|
+
"age_seconds": age_seconds,
|
|
79
|
+
"checked_at": snapshot.checked_at.isoformat(),
|
|
80
|
+
"windows": windows,
|
|
81
|
+
"credits_remaining": snapshot.credits_remaining,
|
|
82
|
+
"warnings": snapshot.warnings,
|
|
83
|
+
"source": snapshot.raw.get("source"),
|
|
84
|
+
"summary": _summary_text(snapshot, windows),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _usable_reason(
|
|
89
|
+
snapshot: CapacitySnapshot,
|
|
90
|
+
windows: list[dict[str, Any]],
|
|
91
|
+
min_remaining_percent: int,
|
|
92
|
+
descriptor: ProviderDescriptor | None,
|
|
93
|
+
stale: bool,
|
|
94
|
+
) -> tuple[bool, str | None]:
|
|
95
|
+
if descriptor and not descriptor.installed:
|
|
96
|
+
return False, "not_installed"
|
|
97
|
+
if descriptor and descriptor.auth.status in {"unauthenticated", "unavailable"}:
|
|
98
|
+
return False, f"auth_{descriptor.auth.status}"
|
|
99
|
+
status = snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status)
|
|
100
|
+
if status in {UsageStatus.LIMIT_REACHED.value, UsageStatus.UNAVAILABLE.value, UsageStatus.UNAUTHENTICATED.value}:
|
|
101
|
+
return False, status
|
|
102
|
+
if status == UsageStatus.UNKNOWN.value:
|
|
103
|
+
return False, "usage_unknown"
|
|
104
|
+
confidence = snapshot.confidence.value if hasattr(snapshot.confidence, "value") else str(snapshot.confidence)
|
|
105
|
+
allowed_confidence = {
|
|
106
|
+
Confidence.OFFICIAL.value,
|
|
107
|
+
Confidence.LOCAL_CLI.value,
|
|
108
|
+
Confidence.LOCAL_CONFIG.value,
|
|
109
|
+
Confidence.USER_CONFIGURED.value,
|
|
110
|
+
}
|
|
111
|
+
if confidence not in allowed_confidence:
|
|
112
|
+
return False, f"confidence_{confidence}"
|
|
113
|
+
if stale:
|
|
114
|
+
return False, "usage_stale"
|
|
115
|
+
for window in windows:
|
|
116
|
+
remaining = window["remaining_percent"]
|
|
117
|
+
if remaining is not None and remaining < min_remaining_percent:
|
|
118
|
+
return False, f"{window['name']}_below_{min_remaining_percent}_percent"
|
|
119
|
+
return True, None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _next_available(rows: dict[str, dict[str, Any]]) -> tuple[str | None, str | None]:
|
|
123
|
+
candidates: list[tuple[datetime, str]] = []
|
|
124
|
+
now = datetime.now(timezone.utc)
|
|
125
|
+
for provider_id, row in rows.items():
|
|
126
|
+
if row["usable"]:
|
|
127
|
+
continue
|
|
128
|
+
for window in row["windows"]:
|
|
129
|
+
reset_at = window.get("reset_at")
|
|
130
|
+
if not reset_at:
|
|
131
|
+
continue
|
|
132
|
+
try:
|
|
133
|
+
parsed = datetime.fromisoformat(reset_at)
|
|
134
|
+
except ValueError:
|
|
135
|
+
continue
|
|
136
|
+
if parsed.tzinfo is None:
|
|
137
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
138
|
+
if parsed > now:
|
|
139
|
+
candidates.append((parsed, provider_id))
|
|
140
|
+
if not candidates:
|
|
141
|
+
return None, None
|
|
142
|
+
reset_at, provider_id = min(candidates, key=lambda item: item[0])
|
|
143
|
+
return provider_id, reset_at.isoformat()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _summary_text(snapshot: CapacitySnapshot, windows: list[dict[str, Any]]) -> str:
|
|
147
|
+
parts: list[str] = []
|
|
148
|
+
seen: set[tuple[str, float | None, float | None]] = set()
|
|
149
|
+
for window in windows:
|
|
150
|
+
label = window["kind"] if window["kind"] != "unknown" else window["name"]
|
|
151
|
+
remaining = window["remaining_percent"]
|
|
152
|
+
key = (label, remaining, window["remaining_units"])
|
|
153
|
+
if key in seen:
|
|
154
|
+
continue
|
|
155
|
+
seen.add(key)
|
|
156
|
+
if remaining is not None:
|
|
157
|
+
parts.append(f"{label} {remaining:g}% left")
|
|
158
|
+
elif window["remaining_units"] is not None:
|
|
159
|
+
parts.append(f"{label} {window['remaining_units']:g} units left")
|
|
160
|
+
if snapshot.credits_remaining is not None:
|
|
161
|
+
parts.append(f"${snapshot.credits_remaining:g} credits")
|
|
162
|
+
if parts:
|
|
163
|
+
return ", ".join(parts)
|
|
164
|
+
if snapshot.warnings:
|
|
165
|
+
return "; ".join(snapshot.warnings)
|
|
166
|
+
return snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status)
|
agentpool/utils.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def utc_now_iso() -> str:
|
|
14
|
+
return datetime.now(timezone.utc).isoformat()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def new_session_id() -> str:
|
|
18
|
+
return f"ap_{uuid.uuid4().hex[:12]}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def repo_hash(path: Path) -> str:
|
|
22
|
+
return hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_file(path: Path) -> str:
|
|
26
|
+
digest = hashlib.sha256()
|
|
27
|
+
with path.open("rb") as fh:
|
|
28
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
29
|
+
digest.update(chunk)
|
|
30
|
+
return digest.hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_json(path: Path, data: Any) -> None:
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True, default=str) + "\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def append_jsonl(path: Path, data: Any) -> None:
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
41
|
+
fh.write(json.dumps(data, sort_keys=True, default=str) + "\n")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_capture(args: list[str], cwd: Path | None = None, timeout: float = 10) -> subprocess.CompletedProcess[str]:
|
|
45
|
+
try:
|
|
46
|
+
return subprocess.run(
|
|
47
|
+
args,
|
|
48
|
+
cwd=str(cwd) if cwd else None,
|
|
49
|
+
text=True,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
check=False,
|
|
53
|
+
)
|
|
54
|
+
except subprocess.TimeoutExpired as exc:
|
|
55
|
+
return subprocess.CompletedProcess(args, 124, exc.stdout or "", exc.stderr or "timed out")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def expand_user_path(value: str) -> Path:
|
|
59
|
+
return Path(os.path.expandvars(value)).expanduser()
|