@caddis/cli 0.4.52 → 0.4.53
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/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +2 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""caddis_spend - one global spend cap for agy, in EUR per rolling 24 hours, on this machine.
|
|
3
|
+
|
|
4
|
+
Why: on 7 Oct 2026 two unsupervised overnight agy runs cost EUR 793 in one billing day. Nothing in
|
|
5
|
+
caddis capped money (caddis_governor caps CPU and memory only). Every agy launch that caddis makes
|
|
6
|
+
through a lane (`caddis_lanes.run_lane`, which ci-watch also uses) or a plan Lane line
|
|
7
|
+
(`caddis_spend.py run -- agy ...`) goes through `reserve()` before it starts and `settle()` after it ends.
|
|
8
|
+
The roadmap/plan runner (`caddis_roadmap_run.py --engine agy`) reserves before each agy session and settles after it.
|
|
9
|
+
|
|
10
|
+
Config `~/.caddis/spend-cap.toml` (the owner writes it; caddis never does):
|
|
11
|
+
|
|
12
|
+
[agy]
|
|
13
|
+
eur_per_24h = 200
|
|
14
|
+
reserve_eur_per_call = 10 # what a call still running, or killed, is assumed to cost
|
|
15
|
+
eur_per_million_input_tokens = 0 # from Google Cloud billing; 0 = unset
|
|
16
|
+
eur_per_million_output_tokens = 0
|
|
17
|
+
|
|
18
|
+
Every value must be a finite number above 0. Anything else means NOT CONFIGURED, and every agy launch is
|
|
19
|
+
refused (fail closed).
|
|
20
|
+
|
|
21
|
+
Log `~/.caddis/spend/agy.jsonl`, append-only. A `pending` line is written under a file lock before the
|
|
22
|
+
launch and a `settled` line with the same id after it. A pending line with no valid settled line keeps
|
|
23
|
+
counting its reserve for 24 h, so a killed or crashed call is never free, and two callers cannot both
|
|
24
|
+
pass the check with the last EUR of the cap.
|
|
25
|
+
|
|
26
|
+
Limits (by design, see .caddis/plans/caddis-next.md Phase 4):
|
|
27
|
+
- The cap is per machine. The only cap across machines is a Google Cloud budget alert.
|
|
28
|
+
- agy started by hand, or by a script outside caddis, is not covered.
|
|
29
|
+
- The cap file and the log are plain files of this user: any process of this user, including an agy
|
|
30
|
+
session started with --dangerously-skip-permissions, can edit them. The cap stops accidents and
|
|
31
|
+
runaway loops; it is not a security boundary against a hostile local process.
|
|
32
|
+
- The check runs at launch. A long call can cost more than its reserve before it is settled; an
|
|
33
|
+
estimated settle records its duration so the overshoot is visible.
|
|
34
|
+
- EUR is an estimate from token counts and the owner's prices, not the real bill.
|
|
35
|
+
|
|
36
|
+
CLI:
|
|
37
|
+
python caddis_spend.py check exit 0 allowed, 1 over cap, 3 not configured
|
|
38
|
+
python caddis_spend.py status prints the committed spend of the last 24 h
|
|
39
|
+
python caddis_spend.py run --caller X -- agy ...
|
|
40
|
+
reserve, run, settle. Returns agy's own exit code, or 75 over the cap / could not lock,
|
|
41
|
+
or 78 not configured. 75 and 78 are never agy's own codes for a refusal, so a caller can
|
|
42
|
+
tell "the cap refused" from "agy failed". Each refusal also prints a `caddis_spend:` line.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import json
|
|
49
|
+
import math
|
|
50
|
+
import os
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
import time
|
|
54
|
+
import uuid
|
|
55
|
+
from contextlib import contextmanager
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
from datetime import datetime, timedelta, timezone
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
EXIT_OK = 0
|
|
61
|
+
EXIT_OVER_CAP = 1 # `check` only
|
|
62
|
+
EXIT_NOT_CONFIGURED = 3 # `check` only
|
|
63
|
+
RUN_REFUSED = 75 # `run`: over the cap, or could not lock (EX_TEMPFAIL)
|
|
64
|
+
RUN_NOT_CONFIGURED = 78 # `run`: not configured (EX_CONFIG)
|
|
65
|
+
LOCK_TIMEOUT_S = 10.0
|
|
66
|
+
NOT_CONFIGURED_MESSAGE = ("caddis_spend: spend cap not configured: set eur_per_24h, reserve_eur_per_call and both "
|
|
67
|
+
"token prices (finite, above 0) in ~/.caddis/spend-cap.toml; every agy launch is refused "
|
|
68
|
+
"until then")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NotConfigured(Exception):
|
|
72
|
+
"""The cap file is missing, unreadable, or holds a value that is not a finite number above 0."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Cap:
|
|
77
|
+
eur_per_24h: float
|
|
78
|
+
reserve: float
|
|
79
|
+
price_in: float
|
|
80
|
+
price_out: float
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _cap_path(home: Path) -> Path:
|
|
84
|
+
return home / ".caddis" / "spend-cap.toml"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _log_path(home: Path) -> Path:
|
|
88
|
+
return home / ".caddis" / "spend" / "agy.jsonl"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _positive(value) -> float:
|
|
92
|
+
"""A finite float above 0, or ValueError (bool and NaN/inf rejected)."""
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
raise ValueError(f"not a number: {value!r}")
|
|
95
|
+
number = float(value)
|
|
96
|
+
if not math.isfinite(number) or number <= 0:
|
|
97
|
+
raise ValueError(f"must be a finite number above 0: {value!r}")
|
|
98
|
+
return number
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_cap(home: Path) -> Cap:
|
|
102
|
+
import tomllib
|
|
103
|
+
|
|
104
|
+
path = _cap_path(home)
|
|
105
|
+
try:
|
|
106
|
+
data = tomllib.loads(path.read_text(encoding="utf-8")).get("agy", {})
|
|
107
|
+
return Cap(_positive(data["eur_per_24h"]), _positive(data.get("reserve_eur_per_call", 10)),
|
|
108
|
+
_positive(data["eur_per_million_input_tokens"]), _positive(data["eur_per_million_output_tokens"]))
|
|
109
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError, tomllib.TOMLDecodeError) as exc:
|
|
110
|
+
raise NotConfigured(f"{path}: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def _locked(home: Path):
|
|
115
|
+
lock = _log_path(home).with_name("agy.lock")
|
|
116
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
fh = open(lock, "a+b")
|
|
118
|
+
deadline = time.monotonic() + LOCK_TIMEOUT_S
|
|
119
|
+
try:
|
|
120
|
+
while True:
|
|
121
|
+
try:
|
|
122
|
+
if sys.platform == "win32":
|
|
123
|
+
import msvcrt
|
|
124
|
+
|
|
125
|
+
fh.seek(0)
|
|
126
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
127
|
+
else:
|
|
128
|
+
import fcntl
|
|
129
|
+
|
|
130
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
131
|
+
break
|
|
132
|
+
except OSError:
|
|
133
|
+
if time.monotonic() >= deadline:
|
|
134
|
+
raise TimeoutError(f"caddis_spend: could not lock {lock} within {LOCK_TIMEOUT_S:g} s")
|
|
135
|
+
time.sleep(0.05)
|
|
136
|
+
yield
|
|
137
|
+
finally:
|
|
138
|
+
try:
|
|
139
|
+
if sys.platform == "win32":
|
|
140
|
+
import msvcrt
|
|
141
|
+
|
|
142
|
+
fh.seek(0)
|
|
143
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
|
144
|
+
else:
|
|
145
|
+
import fcntl
|
|
146
|
+
|
|
147
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
148
|
+
except OSError:
|
|
149
|
+
pass
|
|
150
|
+
fh.close()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def json_mode(argv: list[str]) -> bool:
|
|
154
|
+
"""True only when the command has exactly ONE output-format flag and its value is `json`
|
|
155
|
+
(`--output-format json` or `--output-format=json`). A second flag could switch agy to text, where the
|
|
156
|
+
model's own lines could pose as a usage document."""
|
|
157
|
+
values = []
|
|
158
|
+
for i, arg in enumerate(argv):
|
|
159
|
+
if arg == "--output-format":
|
|
160
|
+
values.append(argv[i + 1] if i + 1 < len(argv) else "")
|
|
161
|
+
elif arg.startswith("--output-format="):
|
|
162
|
+
values.append(arg.split("=", 1)[1])
|
|
163
|
+
return values == ["json"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _read(home: Path) -> list[dict]:
|
|
167
|
+
try:
|
|
168
|
+
text = _log_path(home).read_text(encoding="utf-8", errors="replace")
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
return []
|
|
171
|
+
out = []
|
|
172
|
+
for line in text.splitlines():
|
|
173
|
+
try:
|
|
174
|
+
record = json.loads(line)
|
|
175
|
+
except json.JSONDecodeError:
|
|
176
|
+
continue # a torn line never hides the rest of the log
|
|
177
|
+
if isinstance(record, dict) and isinstance(record.get("id", ""), str):
|
|
178
|
+
out.append(record) # a non-string id (hand edit) is skipped, never a crash
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _append(home: Path, record: dict) -> None:
|
|
183
|
+
path = _log_path(home)
|
|
184
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
186
|
+
fh.write(json.dumps(record) + "\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _when(record: dict) -> datetime | None:
|
|
190
|
+
try:
|
|
191
|
+
when = datetime.fromisoformat(record["ts"])
|
|
192
|
+
except (KeyError, TypeError, ValueError):
|
|
193
|
+
return None
|
|
194
|
+
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _amount(value) -> float | None:
|
|
198
|
+
"""A finite EUR amount of 0 or more, else None (NaN, inf, negative, text and null are not money)."""
|
|
199
|
+
if isinstance(value, bool):
|
|
200
|
+
return None
|
|
201
|
+
try:
|
|
202
|
+
number = float(value)
|
|
203
|
+
except (TypeError, ValueError):
|
|
204
|
+
return None
|
|
205
|
+
return number if math.isfinite(number) and number >= 0 else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def committed_last_24h(home: Path, now: datetime) -> float:
|
|
209
|
+
"""Valid settled EUR, plus the reserve of every pending call with no valid settled line, newer than 24 h.
|
|
210
|
+
|
|
211
|
+
A settled line with a bad `eur` does not count as settled, so its pending reserve keeps counting.
|
|
212
|
+
A pending line with a bad reserve (only a hand edit can write one; reserve() always writes a valid
|
|
213
|
+
value) counts as 0."""
|
|
214
|
+
since = now - timedelta(hours=24)
|
|
215
|
+
records = _read(home)
|
|
216
|
+
settled: dict[str, float] = {}
|
|
217
|
+
for r in records:
|
|
218
|
+
if r.get("kind") == "settled":
|
|
219
|
+
amount = _amount(r.get("eur"))
|
|
220
|
+
when = _when(r)
|
|
221
|
+
if amount is not None and when is not None and isinstance(r.get("id"), str):
|
|
222
|
+
settled[r["id"]] = settled.get(r["id"], 0.0) + (amount if when > since else 0.0)
|
|
223
|
+
total = sum(settled.values())
|
|
224
|
+
for r in records:
|
|
225
|
+
if r.get("kind") != "pending" or r.get("id") in settled:
|
|
226
|
+
continue
|
|
227
|
+
when = _when(r)
|
|
228
|
+
if when is None or when <= since:
|
|
229
|
+
continue
|
|
230
|
+
total += _amount(r.get("reserve_eur")) or 0.0
|
|
231
|
+
return total
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def reserve(home: Path, caller: str, now: datetime) -> str | None:
|
|
235
|
+
"""Book a pending call. None when it would go over the cap. Raises NotConfigured or TimeoutError."""
|
|
236
|
+
cap = load_cap(home)
|
|
237
|
+
with _locked(home):
|
|
238
|
+
if committed_last_24h(home, now) + cap.reserve > cap.eur_per_24h:
|
|
239
|
+
return None
|
|
240
|
+
rid = uuid.uuid4().hex
|
|
241
|
+
_append(home, {"kind": "pending", "id": rid, "ts": now.isoformat(), "caller": caller,
|
|
242
|
+
"pid": os.getpid(), "reserve_eur": cap.reserve})
|
|
243
|
+
return rid
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def settle(home: Path, rid: str, tokens: tuple[int, int] | None, now: datetime,
|
|
247
|
+
duration_s: float | None = None) -> float:
|
|
248
|
+
"""Record the real cost of a reserved call; with no token counts, its reserve (estimated, with duration)."""
|
|
249
|
+
try:
|
|
250
|
+
cap = load_cap(home)
|
|
251
|
+
except NotConfigured:
|
|
252
|
+
cap = None
|
|
253
|
+
record: dict = {"kind": "settled", "id": rid, "ts": now.isoformat()}
|
|
254
|
+
if tokens is None or cap is None:
|
|
255
|
+
pending = next((r for r in _read(home) if r.get("kind") == "pending" and r.get("id") == rid), {})
|
|
256
|
+
eur = _amount(pending.get("reserve_eur"))
|
|
257
|
+
if eur is None:
|
|
258
|
+
eur = cap.reserve if cap else 0.0
|
|
259
|
+
record.update({"tokens_in": 0, "tokens_out": 0, "eur": round(eur, 4), "estimated": True})
|
|
260
|
+
else:
|
|
261
|
+
t_in, t_out = tokens
|
|
262
|
+
eur = t_in / 1e6 * cap.price_in + t_out / 1e6 * cap.price_out
|
|
263
|
+
record.update({"tokens_in": t_in, "tokens_out": t_out, "eur": round(eur, 4), "estimated": False})
|
|
264
|
+
if duration_s is not None:
|
|
265
|
+
record["duration_s"] = round(duration_s, 1)
|
|
266
|
+
with _locked(home):
|
|
267
|
+
_append(home, record)
|
|
268
|
+
return eur
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _agy_document(stdout: str) -> dict | None:
|
|
272
|
+
"""agy's own JSON document from `agy --output-format json` (sample: scripts/tests/fixtures/agy-json-sample.json).
|
|
273
|
+
|
|
274
|
+
The document may follow other lines (on Windows the governor writes `[caddis-governor] scope=...` first,
|
|
275
|
+
and run_lane merges stderr into stdout), and agy pretty-prints it over several lines. So: try every `{`
|
|
276
|
+
that starts a line, decode one JSON value from there, and keep the LAST dict that has agy's top-level
|
|
277
|
+
keys (`status`, `response`, `usage`). The model's reply is inside the `response` string, where a `{`
|
|
278
|
+
never starts a line of the raw output, so text the model prints cannot pose as the usage document."""
|
|
279
|
+
decoder = json.JSONDecoder()
|
|
280
|
+
text = stdout or ""
|
|
281
|
+
found = None
|
|
282
|
+
starts = [0] if text.startswith("{") else []
|
|
283
|
+
starts += [i + 1 for i, ch in enumerate(text) if ch == "\n" and text[i + 1:i + 2] == "{"]
|
|
284
|
+
for start in starts:
|
|
285
|
+
try:
|
|
286
|
+
obj, _end = decoder.raw_decode(text, start)
|
|
287
|
+
except json.JSONDecodeError:
|
|
288
|
+
continue
|
|
289
|
+
if isinstance(obj, dict) and {"status", "response", "usage"} <= obj.keys():
|
|
290
|
+
found = obj
|
|
291
|
+
return found
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _count(value) -> int | None:
|
|
295
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
296
|
+
return None
|
|
297
|
+
return value
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def tokens_from_output(stdout: str) -> tuple[int, int] | None:
|
|
301
|
+
"""(usage.input_tokens, usage.output_tokens) from agy's JSON document; output_tokens includes thinking
|
|
302
|
+
tokens (sample: 27249 in, 85 out of which 84 thinking, total 27334). None when not readable, or when a
|
|
303
|
+
count is not a non-negative integer (then the call settles at its reserve)."""
|
|
304
|
+
doc = _agy_document(stdout)
|
|
305
|
+
usage = doc.get("usage") if doc else None
|
|
306
|
+
if not isinstance(usage, dict):
|
|
307
|
+
return None
|
|
308
|
+
t_in, t_out = _count(usage.get("input_tokens")), _count(usage.get("output_tokens"))
|
|
309
|
+
return (t_in, t_out) if t_in is not None and t_out is not None else None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def result_text(stdout: str) -> str:
|
|
313
|
+
"""The `response` text from agy's JSON document; the raw text when there is no such document."""
|
|
314
|
+
doc = _agy_document(stdout)
|
|
315
|
+
if doc and isinstance(doc.get("response"), str):
|
|
316
|
+
return doc["response"]
|
|
317
|
+
return stdout
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def run(argv: list[str], caller: str, home: Path) -> int:
|
|
321
|
+
"""Reserve, run agy, settle. Returns agy's exit code, or RUN_REFUSED (75) / RUN_NOT_CONFIGURED (78)."""
|
|
322
|
+
now = lambda: datetime.now(timezone.utc) # noqa: E731
|
|
323
|
+
try:
|
|
324
|
+
rid = reserve(home, caller, now())
|
|
325
|
+
except NotConfigured:
|
|
326
|
+
print(NOT_CONFIGURED_MESSAGE, file=sys.stderr)
|
|
327
|
+
return RUN_NOT_CONFIGURED
|
|
328
|
+
except TimeoutError as exc:
|
|
329
|
+
print(str(exc), file=sys.stderr)
|
|
330
|
+
return RUN_REFUSED
|
|
331
|
+
except Exception as exc: # an unreadable log or file error: refuse with the refusal code, never exit 1
|
|
332
|
+
print(f"caddis_spend: could not check the spend cap: {exc!r}", file=sys.stderr)
|
|
333
|
+
return RUN_REFUSED
|
|
334
|
+
if rid is None:
|
|
335
|
+
print(f"caddis_spend: over cap: agy refused ({status_line(home, now())})", file=sys.stderr)
|
|
336
|
+
return RUN_REFUSED
|
|
337
|
+
env = {**os.environ, "CADDIS_HEADLESS": "1"}
|
|
338
|
+
out = ""
|
|
339
|
+
counts: tuple[int, int] | None = None
|
|
340
|
+
started = time.monotonic()
|
|
341
|
+
try:
|
|
342
|
+
done = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace", env=env)
|
|
343
|
+
out = done.stdout or ""
|
|
344
|
+
# Tokens only from JSON mode: in text mode the model's own lines could pose as a usage document.
|
|
345
|
+
if json_mode(argv):
|
|
346
|
+
counts = tokens_from_output(out)
|
|
347
|
+
sys.stdout.write(out)
|
|
348
|
+
sys.stderr.write(done.stderr or "")
|
|
349
|
+
return done.returncode
|
|
350
|
+
except FileNotFoundError:
|
|
351
|
+
counts = (0, 0) # nothing ran, nothing was spent
|
|
352
|
+
print(f"caddis_spend: command not found: {argv[0]}", file=sys.stderr)
|
|
353
|
+
return 127
|
|
354
|
+
finally:
|
|
355
|
+
try:
|
|
356
|
+
settle(home, rid, counts, now(), duration_s=time.monotonic() - started)
|
|
357
|
+
except Exception as exc: # the pending reserve keeps counting; never lose agy's own result
|
|
358
|
+
print(f"caddis_spend: could not settle {rid}: {exc}", file=sys.stderr)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def status_line(home: Path, now: datetime) -> str:
|
|
362
|
+
try:
|
|
363
|
+
cap = f"{load_cap(home).eur_per_24h:g}"
|
|
364
|
+
except NotConfigured:
|
|
365
|
+
cap = "NOT CONFIGURED"
|
|
366
|
+
return f"agy: EUR {committed_last_24h(home, now):.2f} of {cap} in the last 24 h on this machine"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def main(argv: list[str] | None = None) -> int:
|
|
370
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
371
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
372
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
373
|
+
tail: list[str] = []
|
|
374
|
+
if "--" in raw:
|
|
375
|
+
i = raw.index("--")
|
|
376
|
+
raw, tail = raw[:i], raw[i + 1:]
|
|
377
|
+
parser = argparse.ArgumentParser(description="global agy spend cap (EUR per rolling 24 h, this machine)")
|
|
378
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
379
|
+
sub.add_parser("check")
|
|
380
|
+
sub.add_parser("status")
|
|
381
|
+
p_run = sub.add_parser("run")
|
|
382
|
+
p_run.add_argument("--caller", required=True)
|
|
383
|
+
args = parser.parse_args(raw)
|
|
384
|
+
home = Path.home()
|
|
385
|
+
now = datetime.now(timezone.utc)
|
|
386
|
+
if args.cmd == "status":
|
|
387
|
+
try:
|
|
388
|
+
print(status_line(home, now))
|
|
389
|
+
except Exception as exc:
|
|
390
|
+
print(f"caddis_spend: could not read the spend log: {exc!r}", file=sys.stderr)
|
|
391
|
+
return EXIT_NOT_CONFIGURED
|
|
392
|
+
return EXIT_OK
|
|
393
|
+
if args.cmd == "check":
|
|
394
|
+
try:
|
|
395
|
+
cap = load_cap(home)
|
|
396
|
+
except NotConfigured:
|
|
397
|
+
print(NOT_CONFIGURED_MESSAGE, file=sys.stderr)
|
|
398
|
+
return EXIT_NOT_CONFIGURED
|
|
399
|
+
try:
|
|
400
|
+
committed = committed_last_24h(home, now)
|
|
401
|
+
except Exception as exc:
|
|
402
|
+
print(f"caddis_spend: could not read the spend log: {exc!r}", file=sys.stderr)
|
|
403
|
+
return EXIT_NOT_CONFIGURED
|
|
404
|
+
if committed + cap.reserve > cap.eur_per_24h:
|
|
405
|
+
print(f"over cap: {status_line(home, now)}")
|
|
406
|
+
return EXIT_OVER_CAP
|
|
407
|
+
print(f"ok: {status_line(home, now)}")
|
|
408
|
+
return EXIT_OK
|
|
409
|
+
if not tail:
|
|
410
|
+
parser.error("run needs the agy command after --")
|
|
411
|
+
return run(tail, args.caller, home)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
if __name__ == "__main__":
|
|
415
|
+
sys.exit(main())
|