delimit-cli 4.5.7 → 4.5.9
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/CHANGELOG.md +6 -6
- package/gateway/ai/backends/gateway_core.py +62 -1
- package/gateway/ai/governance.py +1 -1
- package/gateway/ai/led193_daemon/__init__.py +61 -0
- package/gateway/ai/led193_daemon/audit.py +174 -0
- package/gateway/ai/led193_daemon/cost.py +133 -0
- package/gateway/ai/led193_daemon/executor.py +683 -0
- package/gateway/ai/led193_daemon/gate.py +300 -0
- package/gateway/ai/led193_daemon/pause.py +83 -0
- package/gateway/ai/led193_daemon/picker.py +236 -0
- package/gateway/ai/social_capability/current_capabilities.yaml +1 -0
- package/gateway/ai/workers/executor.py +18 -9
- package/gateway/core/contract_ledger.py +1 -1
- package/gateway/core/dependency_graph.py +1 -1
- package/gateway/core/dependency_manifest.py +1 -1
- package/gateway/core/event_backbone.py +2 -2
- package/gateway/core/event_schema.py +1 -1
- package/gateway/core/impact_analyzer.py +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -219,7 +219,7 @@ Real-world specs can ship malformed shapes. The diff engine now defends against
|
|
|
219
219
|
|
|
220
220
|
### Fixed
|
|
221
221
|
- **Exit-shim counter undercounting** — previously missed commits outside `SESSION_CWD` and dropped Z-suffixed timestamps; both now captured.
|
|
222
|
-
- **Proprietary path leaks** — sync-gateway.sh EXCLUDE list hardened to keep
|
|
222
|
+
- **Proprietary path leaks** — sync-gateway.sh EXCLUDE list hardened to keep portfolio-specific files (social.py, social_target.py, inbox_daemon.py, founding_users.py, deliberation.py) out of the npm bundle.
|
|
223
223
|
|
|
224
224
|
### Tests
|
|
225
225
|
- Gateway: 163/163 passing on changed-file tests (social.py, social_target.py, supabase_sync).
|
|
@@ -610,8 +610,8 @@ Real-world specs can ship malformed shapes. The diff engine now defends against
|
|
|
610
610
|
- Wire local API server into setup flow (STR-057) (223a647d)
|
|
611
611
|
- release: v3.11.4 — CLAUDE.md auto-update with versioned markers (66db96dd)
|
|
612
612
|
- security: remove infect.js, hardcoded paths, stale shell scripts (ddb2b1a8)
|
|
613
|
-
- security: remove all
|
|
614
|
-
- security: remove
|
|
613
|
+
- security: remove all internal doctrine references from gateway stubs (1d802a2b)
|
|
614
|
+
- security: remove holdco adapters from public repo (c976fa8a)
|
|
615
615
|
- release: v3.11.1 — MCP/AI keywords for npm discoverability (ba984f17)
|
|
616
616
|
- release: v3.11.0 — agent identity, secrets broker, approval gates (78557ea8)
|
|
617
617
|
- update: CLI description to match brand positioning (75ff6842)
|
|
@@ -623,7 +623,7 @@ Real-world specs can ship malformed shapes. The diff engine now defends against
|
|
|
623
623
|
- v3.9.1: download Pro modules from delimit.ai CDN (public URL, no auth needed) (b1462fd4)
|
|
624
624
|
- v3.9.0: Pro source removed from public package — compiled modules download at install (e4fe7baf)
|
|
625
625
|
- v3.8.2: Gemini governance trigger + history scrub (116ffb1a)
|
|
626
|
-
- security: remove node_modules and
|
|
626
|
+
- security: remove node_modules and holdco adapters from public repo (f67f3b92)
|
|
627
627
|
- v3.8.1: governance trigger in all instruction files + MCP server description (7c525231)
|
|
628
628
|
- v3.7.1: CLI-first deliberation + gateway sync + path cleanup (1555691d)
|
|
629
629
|
- v3.7.0: cross-model positioning + models configure + release sync (4c9cbcb7)
|
|
@@ -698,7 +698,7 @@ Real-world specs can ship malformed shapes. The diff engine now defends against
|
|
|
698
698
|
- **LED-061**: [P0] DomainVested: Consistency audit — verdict/flip/action must agree
|
|
699
699
|
- **LED-062**: [P1] Brand: Add SVG logo to site, favicon, GitHub org avatar, npm
|
|
700
700
|
- **LED-063**: [P0] Governance trigger shipped in npm — instruction files + MCP description
|
|
701
|
-
- **LED-064**: [P0] Security: removed
|
|
701
|
+
- **LED-064**: [P0] Security: removed holdco adapters + node_modules from public repo
|
|
702
702
|
- **LED-065**: [P0] ChatOps: Build app.delimit.ai into a unified project management interface
|
|
703
703
|
- **LED-066**: [P0] Split repos: free tools public, Pro tools private, npm bundles both
|
|
704
704
|
- **LED-067**: [P0] License: add periodic re-validation (30 day) with 7 day grace period
|
|
@@ -1064,7 +1064,7 @@ Real-world specs can ship malformed shapes. The diff engine now defends against
|
|
|
1064
1064
|
- GitHub Action smoke test workflow
|
|
1065
1065
|
|
|
1066
1066
|
### Fixed
|
|
1067
|
-
- Gemini deliberation HTTP 400 (ADC credentials +
|
|
1067
|
+
- Gemini deliberation HTTP 400 (ADC credentials + project mismatch)
|
|
1068
1068
|
- Deliberation timeout: parallelized round 1 (46% faster)
|
|
1069
1069
|
- Sensor dedup: titles include repo/issue to prevent duplicates
|
|
1070
1070
|
- Test-mode guard prevents ledger pollution from tests
|
|
@@ -8,6 +8,8 @@ Adapter Boundary Contract v1.0:
|
|
|
8
8
|
- No schema forking (gateway types are canonical)
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
11
13
|
import sys
|
|
12
14
|
import json
|
|
13
15
|
import logging
|
|
@@ -16,6 +18,58 @@ from typing import Any, Dict, List, Optional
|
|
|
16
18
|
|
|
17
19
|
logger = logging.getLogger("delimit.ai.gateway_core")
|
|
18
20
|
|
|
21
|
+
|
|
22
|
+
# LED-1265: identity-string redaction filter for changelog output. Patterns are
|
|
23
|
+
# loaded from the DELIMIT_CHANGELOG_REDACT_PATTERNS env var (semicolon-separated
|
|
24
|
+
# regex|replacement pairs, e.g. "FOO|[redacted];BAR|[redacted]"), NOT hardcoded.
|
|
25
|
+
# Why env-var-driven: hardcoding the patterns inline would itself constitute the
|
|
26
|
+
# leak the filter exists to prevent — the patterns must match the strings being
|
|
27
|
+
# redacted, so committing them to source recreates the leak. When the env var is
|
|
28
|
+
# unset (customer machines), this is a no-op pass-through. The internal gateway
|
|
29
|
+
# sets the env var at process start with the exact patterns to scrub before any
|
|
30
|
+
# auto-generated changelog reaches a public surface.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_redaction_patterns() -> List[tuple]:
|
|
34
|
+
"""Parse DELIMIT_CHANGELOG_REDACT_PATTERNS env var into compiled patterns.
|
|
35
|
+
|
|
36
|
+
Format: semicolon-separated `regex|replacement` pairs. Empty / unset returns [].
|
|
37
|
+
Invalid regexes are warned and skipped (filter is fail-open: better to skip a
|
|
38
|
+
bad pattern than block all output).
|
|
39
|
+
"""
|
|
40
|
+
raw = os.environ.get("DELIMIT_CHANGELOG_REDACT_PATTERNS", "").strip()
|
|
41
|
+
if not raw:
|
|
42
|
+
return []
|
|
43
|
+
patterns = []
|
|
44
|
+
for entry in raw.split(";"):
|
|
45
|
+
entry = entry.strip()
|
|
46
|
+
if not entry or "|" not in entry:
|
|
47
|
+
continue
|
|
48
|
+
pat, _, repl = entry.partition("|")
|
|
49
|
+
try:
|
|
50
|
+
patterns.append((re.compile(pat), repl))
|
|
51
|
+
except re.error as exc:
|
|
52
|
+
logger.warning("ignoring invalid changelog redaction pattern %r: %s", pat, exc)
|
|
53
|
+
return patterns
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_IDENTITY_STRING_PATTERNS = _load_redaction_patterns()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _redact_identity_strings(text: str) -> str:
|
|
60
|
+
"""Apply env-var-loaded redaction patterns to text. No-op when env var unset.
|
|
61
|
+
|
|
62
|
+
Defensive filter for changelog generation: immutable commit messages on
|
|
63
|
+
public repos may contain identity strings the auto-generated CHANGELOG would
|
|
64
|
+
re-leak to a fresh public surface. The filter is opt-in per gateway
|
|
65
|
+
deployment via env var; customers don't need to set it.
|
|
66
|
+
"""
|
|
67
|
+
if not text or not _IDENTITY_STRING_PATTERNS:
|
|
68
|
+
return text
|
|
69
|
+
for pattern, replacement in _IDENTITY_STRING_PATTERNS:
|
|
70
|
+
text = pattern.sub(replacement, text)
|
|
71
|
+
return text
|
|
72
|
+
|
|
19
73
|
# Add gateway root to path so we can import core modules
|
|
20
74
|
GATEWAY_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
21
75
|
if str(GATEWAY_ROOT) not in sys.path:
|
|
@@ -582,6 +636,11 @@ def run_changelog_from_git(
|
|
|
582
636
|
ctype = cat
|
|
583
637
|
break
|
|
584
638
|
|
|
639
|
+
# LED-1265: redact founder-holdco identity strings from the commit
|
|
640
|
+
# subject (msg) and author before they enter any output path.
|
|
641
|
+
msg = _redact_identity_strings(msg)
|
|
642
|
+
author = _redact_identity_strings(author)
|
|
643
|
+
|
|
585
644
|
bucket = ctype if ctype in categories else "other"
|
|
586
645
|
entry = {"sha": sha[:8], "message": msg, "author": author, "category": bucket}
|
|
587
646
|
categories[bucket].append(entry)
|
|
@@ -641,9 +700,11 @@ def run_changelog_from_git(
|
|
|
641
700
|
continue
|
|
642
701
|
except (ValueError, TypeError):
|
|
643
702
|
pass # If parsing fails, include the item
|
|
703
|
+
# LED-1265: ledger titles may contain identity strings
|
|
704
|
+
# (e.g. LED items filed before the doctrine bound).
|
|
644
705
|
ledger_items.append({
|
|
645
706
|
"id": item_id,
|
|
646
|
-
"title": item.get("title", ""),
|
|
707
|
+
"title": _redact_identity_strings(item.get("title", "")),
|
|
647
708
|
"priority": item.get("priority", ""),
|
|
648
709
|
})
|
|
649
710
|
except Exception:
|
package/gateway/ai/governance.py
CHANGED
|
@@ -49,7 +49,7 @@ NON_DELEGABLE_OPERATION_CLASSES = frozenset({
|
|
|
49
49
|
"constitutional_rewrite", # edits to founder doctrine canon outside managed sections
|
|
50
50
|
"authority_class_expansion", # adding a new class of tool / agent / gate
|
|
51
51
|
"irreversible_capital_commit", # capital commitments above non-delegable threshold
|
|
52
|
-
"venture_kill", # shutting down
|
|
52
|
+
"venture_kill", # shutting down an internal venture
|
|
53
53
|
"permission_escalation", # granting elevated access (sudo, admin, write-as-other)
|
|
54
54
|
"public_truth_claim", # public statement / marketing assertion outrunning evidence
|
|
55
55
|
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""LED-193 autonomous daemon (MVP).
|
|
2
|
+
|
|
3
|
+
Cron-spawn, stateless, append-only audit. Picks ledger items tagged
|
|
4
|
+
``auto_execute=class_a:<profile>`` and executes a deterministic profile
|
|
5
|
+
(``format_fix``, ``lockfile_refresh``, ``docs_typo``) on a feature branch.
|
|
6
|
+
NEVER merges. Opens a PR for human review only after local pre-push gates
|
|
7
|
+
pass (security_audit + test_smoke + lint when applicable).
|
|
8
|
+
|
|
9
|
+
Panel decision (UNANIMOUS, 2026-05-07):
|
|
10
|
+
`/home/delimit/delimit-private/deliberations/2026-05-07-led-193-autonomous-daemon-shape.md`
|
|
11
|
+
|
|
12
|
+
Design siblings the cron pattern of LED-1264 ``scan_bridge``:
|
|
13
|
+
- cron-spawn (no long-running process)
|
|
14
|
+
- lockfile concurrency=1
|
|
15
|
+
- append-only audit log
|
|
16
|
+
- kill switch via env var
|
|
17
|
+
- circuit breakers (consecutive failures, daily caps)
|
|
18
|
+
|
|
19
|
+
Public entry points:
|
|
20
|
+
|
|
21
|
+
- :func:`picker.pick_next_item` — ledger-item selection
|
|
22
|
+
- :func:`executor.execute_item` — profile dispatch
|
|
23
|
+
- :func:`gate.run_pre_push_gate` — local pre-push validation
|
|
24
|
+
- :func:`audit.log_execution` — append-only execution log
|
|
25
|
+
- :func:`pause.is_paused` / :func:`pause.pause` / :func:`pause.clear`
|
|
26
|
+
- :func:`cost.check_caps` / :func:`cost.record_run`
|
|
27
|
+
|
|
28
|
+
The cron entry is :mod:`scripts.led193_cron`. Founder applies the
|
|
29
|
+
crontab line manually after review (NOT auto-installed).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from ai.led193_daemon.audit import log_execution
|
|
33
|
+
from ai.led193_daemon.cost import check_caps, record_run
|
|
34
|
+
from ai.led193_daemon.executor import execute_item
|
|
35
|
+
from ai.led193_daemon.gate import run_pre_push_gate
|
|
36
|
+
from ai.led193_daemon.pause import clear as clear_pause
|
|
37
|
+
from ai.led193_daemon.pause import is_paused, pause as pause_daemon
|
|
38
|
+
from ai.led193_daemon.picker import pick_next_item
|
|
39
|
+
|
|
40
|
+
# Re-export the submodules so callers can do
|
|
41
|
+
# ``from ai.led193_daemon import audit, cost, executor, gate, pause, picker``
|
|
42
|
+
# without the function-named exports above shadowing the ``pause`` module.
|
|
43
|
+
from ai.led193_daemon import audit, cost, executor, gate, pause, picker # noqa: E402,F401
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"audit",
|
|
47
|
+
"check_caps",
|
|
48
|
+
"clear_pause",
|
|
49
|
+
"cost",
|
|
50
|
+
"execute_item",
|
|
51
|
+
"executor",
|
|
52
|
+
"gate",
|
|
53
|
+
"is_paused",
|
|
54
|
+
"log_execution",
|
|
55
|
+
"pause",
|
|
56
|
+
"pause_daemon",
|
|
57
|
+
"pick_next_item",
|
|
58
|
+
"picker",
|
|
59
|
+
"record_run",
|
|
60
|
+
"run_pre_push_gate",
|
|
61
|
+
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""LED-193 append-only execution audit log.
|
|
2
|
+
|
|
3
|
+
Every pickup attempt logs one JSON line — success or failure — so
|
|
4
|
+
incidents can be replayed against the daemon's actual behaviour.
|
|
5
|
+
|
|
6
|
+
Schema:
|
|
7
|
+
{
|
|
8
|
+
"ts": ISO8601 UTC,
|
|
9
|
+
"item_id": str,
|
|
10
|
+
"profile": "format_fix" | "lockfile_refresh" | "docs_typo" | "",
|
|
11
|
+
"branch": str | "", # auto/{profile}-{item_id}-{short_hash}
|
|
12
|
+
"pr_url": str | "", # populated only on success
|
|
13
|
+
"result": "success" | "failed" | "noop" | "skipped" | "ci_failed_after_open",
|
|
14
|
+
"reason": str, # human-readable detail
|
|
15
|
+
"cost_estimate": float, # USD; 0.0 for deterministic profiles
|
|
16
|
+
"files_changed": int,
|
|
17
|
+
# optional, populated when known:
|
|
18
|
+
"elapsed_s": float,
|
|
19
|
+
"gate_results": dict,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
The log is append-only — never rewritten. Path:
|
|
23
|
+
``~/.delimit/led193_executions.jsonl``
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Dict, Optional
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("delimit.ai.led193_daemon.audit")
|
|
36
|
+
|
|
37
|
+
AUDIT_LOG = Path.home() / ".delimit" / "led193_executions.jsonl"
|
|
38
|
+
|
|
39
|
+
VALID_RESULTS = {
|
|
40
|
+
"success",
|
|
41
|
+
"failed",
|
|
42
|
+
"noop",
|
|
43
|
+
"skipped",
|
|
44
|
+
"ci_failed_after_open",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def log_execution(
|
|
49
|
+
*,
|
|
50
|
+
item_id: str,
|
|
51
|
+
profile: str = "",
|
|
52
|
+
branch: str = "",
|
|
53
|
+
pr_url: str = "",
|
|
54
|
+
result: str = "failed",
|
|
55
|
+
reason: str = "",
|
|
56
|
+
cost_estimate: float = 0.0,
|
|
57
|
+
files_changed: int = 0,
|
|
58
|
+
elapsed_s: Optional[float] = None,
|
|
59
|
+
gate_results: Optional[Dict[str, Any]] = None,
|
|
60
|
+
audit_log_path: Optional[Path] = None,
|
|
61
|
+
) -> Dict[str, Any]:
|
|
62
|
+
"""Write one append-only audit line.
|
|
63
|
+
|
|
64
|
+
Returns the record actually written (useful for tests + the cron
|
|
65
|
+
summary). Best-effort — a write failure logs a warning but never
|
|
66
|
+
raises (the daemon must not crash on disk-full).
|
|
67
|
+
"""
|
|
68
|
+
if result not in VALID_RESULTS:
|
|
69
|
+
# Don't reject — coerce to "failed" with a clarifying reason so
|
|
70
|
+
# we never silently drop an audit row over a typo in caller code.
|
|
71
|
+
reason = f"invalid_result={result!r}; original_reason={reason!r}"
|
|
72
|
+
result = "failed"
|
|
73
|
+
|
|
74
|
+
record: Dict[str, Any] = {
|
|
75
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
76
|
+
"item_id": item_id,
|
|
77
|
+
"profile": profile,
|
|
78
|
+
"branch": branch,
|
|
79
|
+
"pr_url": pr_url,
|
|
80
|
+
"result": result,
|
|
81
|
+
"reason": reason,
|
|
82
|
+
"cost_estimate": float(cost_estimate),
|
|
83
|
+
"files_changed": int(files_changed),
|
|
84
|
+
}
|
|
85
|
+
if elapsed_s is not None:
|
|
86
|
+
record["elapsed_s"] = round(float(elapsed_s), 3)
|
|
87
|
+
if gate_results is not None:
|
|
88
|
+
record["gate_results"] = gate_results
|
|
89
|
+
|
|
90
|
+
target = audit_log_path or AUDIT_LOG
|
|
91
|
+
try:
|
|
92
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
with target.open("a", encoding="utf-8") as fh:
|
|
94
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
95
|
+
except OSError as exc: # pragma: no cover — best-effort
|
|
96
|
+
logger.warning("led193_daemon: failed to write audit log %s: %s", target, exc)
|
|
97
|
+
|
|
98
|
+
return record
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def recent_results(
|
|
102
|
+
*,
|
|
103
|
+
audit_log_path: Optional[Path] = None,
|
|
104
|
+
limit: int = 100,
|
|
105
|
+
) -> list:
|
|
106
|
+
"""Read the most recent N records from the audit log (newest first).
|
|
107
|
+
|
|
108
|
+
Used by the consecutive-failures circuit breaker and by the cron
|
|
109
|
+
summary. Returns ``[]`` when the file doesn't exist or is empty.
|
|
110
|
+
"""
|
|
111
|
+
target = audit_log_path or AUDIT_LOG
|
|
112
|
+
if not target.exists():
|
|
113
|
+
return []
|
|
114
|
+
try:
|
|
115
|
+
lines = target.read_text(encoding="utf-8").splitlines()
|
|
116
|
+
except OSError:
|
|
117
|
+
return []
|
|
118
|
+
out = []
|
|
119
|
+
for line in reversed(lines):
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if not line:
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
out.append(json.loads(line))
|
|
125
|
+
except (json.JSONDecodeError, ValueError):
|
|
126
|
+
continue
|
|
127
|
+
if len(out) >= limit:
|
|
128
|
+
break
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def consecutive_failures(
|
|
133
|
+
*,
|
|
134
|
+
audit_log_path: Optional[Path] = None,
|
|
135
|
+
) -> int:
|
|
136
|
+
"""Count CONSECUTIVE failures from the most recent record backward.
|
|
137
|
+
|
|
138
|
+
Stops at the first non-failure (success/noop/skipped). Used by the
|
|
139
|
+
3-strike circuit breaker. ``ci_failed_after_open`` counts as a
|
|
140
|
+
failure for breaker purposes — if the daemon keeps opening PRs that
|
|
141
|
+
break CI, that's a signal to pause.
|
|
142
|
+
"""
|
|
143
|
+
failures = 0
|
|
144
|
+
for rec in recent_results(audit_log_path=audit_log_path, limit=20):
|
|
145
|
+
if rec.get("result") in ("failed", "ci_failed_after_open"):
|
|
146
|
+
failures += 1
|
|
147
|
+
continue
|
|
148
|
+
break
|
|
149
|
+
return failures
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def prs_opened_today(
|
|
153
|
+
*,
|
|
154
|
+
audit_log_path: Optional[Path] = None,
|
|
155
|
+
now: Optional[datetime] = None,
|
|
156
|
+
) -> int:
|
|
157
|
+
"""Count successful PRs opened in the last 24 hours.
|
|
158
|
+
|
|
159
|
+
Used by the action-volume circuit breaker (max 5 PRs / day).
|
|
160
|
+
"""
|
|
161
|
+
now = now or datetime.now(timezone.utc)
|
|
162
|
+
cutoff = now.timestamp() - 86400.0
|
|
163
|
+
n = 0
|
|
164
|
+
for rec in recent_results(audit_log_path=audit_log_path, limit=200):
|
|
165
|
+
if rec.get("result") != "success" or not rec.get("pr_url"):
|
|
166
|
+
continue
|
|
167
|
+
ts = rec.get("ts") or ""
|
|
168
|
+
try:
|
|
169
|
+
rec_dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
170
|
+
if rec_dt.timestamp() >= cutoff:
|
|
171
|
+
n += 1
|
|
172
|
+
except (ValueError, TypeError):
|
|
173
|
+
continue
|
|
174
|
+
return n
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""LED-193 cost tracking + circuit breakers.
|
|
2
|
+
|
|
3
|
+
MVP profiles are deterministic (no LLM): cost is always 0.0. The cost
|
|
4
|
+
infrastructure exists ahead of time so when Class C ``bounded_patch``
|
|
5
|
+
graduates, the breakers are wired and unit-tested rather than bolted
|
|
6
|
+
on under pressure.
|
|
7
|
+
|
|
8
|
+
Hard caps (panel-locked):
|
|
9
|
+
- Per-item LLM cost: $2 (DELIMIT_LED193_PER_ITEM_USD override)
|
|
10
|
+
- Daily LLM cost: $10 (DELIMIT_LED193_DAILY_USD override)
|
|
11
|
+
|
|
12
|
+
Daily window = trailing 24h, summed from the audit log
|
|
13
|
+
(``cost_estimate`` field). Per-item is enforced by callers BEFORE
|
|
14
|
+
incurring the cost — exceeded → return ``CapTriggered`` and the executor
|
|
15
|
+
short-circuits.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from ai.led193_daemon.audit import recent_results
|
|
26
|
+
|
|
27
|
+
DEFAULT_PER_ITEM_USD = 2.00
|
|
28
|
+
DEFAULT_DAILY_USD = 10.00
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def per_item_cap() -> float:
|
|
32
|
+
raw = os.environ.get("DELIMIT_LED193_PER_ITEM_USD", "")
|
|
33
|
+
if raw:
|
|
34
|
+
try:
|
|
35
|
+
v = float(raw)
|
|
36
|
+
if v >= 0:
|
|
37
|
+
return v
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
pass
|
|
40
|
+
return DEFAULT_PER_ITEM_USD
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def daily_cap() -> float:
|
|
44
|
+
raw = os.environ.get("DELIMIT_LED193_DAILY_USD", "")
|
|
45
|
+
if raw:
|
|
46
|
+
try:
|
|
47
|
+
v = float(raw)
|
|
48
|
+
if v >= 0:
|
|
49
|
+
return v
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
pass
|
|
52
|
+
return DEFAULT_DAILY_USD
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def daily_spend(
|
|
56
|
+
*,
|
|
57
|
+
audit_log_path: Optional[Path] = None,
|
|
58
|
+
now: Optional[datetime] = None,
|
|
59
|
+
) -> float:
|
|
60
|
+
"""Sum of cost_estimate across audit records in the last 24h."""
|
|
61
|
+
now = now or datetime.now(timezone.utc)
|
|
62
|
+
cutoff = now.timestamp() - 86400.0
|
|
63
|
+
total = 0.0
|
|
64
|
+
for rec in recent_results(audit_log_path=audit_log_path, limit=500):
|
|
65
|
+
ts = rec.get("ts") or ""
|
|
66
|
+
try:
|
|
67
|
+
rec_dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
68
|
+
except (ValueError, TypeError):
|
|
69
|
+
continue
|
|
70
|
+
if rec_dt.timestamp() < cutoff:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
total += float(rec.get("cost_estimate") or 0.0)
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
continue
|
|
76
|
+
return total
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_caps(
|
|
80
|
+
*,
|
|
81
|
+
estimated_cost: float = 0.0,
|
|
82
|
+
audit_log_path: Optional[Path] = None,
|
|
83
|
+
now: Optional[datetime] = None,
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""Return ``{"ok": bool, "reason": str, ...}``.
|
|
86
|
+
|
|
87
|
+
Caller passes ``estimated_cost`` for the proposed item; we check
|
|
88
|
+
BOTH the per-item cap AND the projected daily total. Deterministic
|
|
89
|
+
profiles pass ``estimated_cost=0.0`` and always return ``ok=True``
|
|
90
|
+
unless the daily cap is already breached (which would only happen
|
|
91
|
+
under a misconfigured override).
|
|
92
|
+
"""
|
|
93
|
+
per_cap = per_item_cap()
|
|
94
|
+
if estimated_cost > per_cap:
|
|
95
|
+
return {
|
|
96
|
+
"ok": False,
|
|
97
|
+
"reason": f"per_item_cap_exceeded: ${estimated_cost:.2f} > ${per_cap:.2f}",
|
|
98
|
+
"estimated_cost": estimated_cost,
|
|
99
|
+
"per_item_cap": per_cap,
|
|
100
|
+
}
|
|
101
|
+
spent = daily_spend(audit_log_path=audit_log_path, now=now)
|
|
102
|
+
d_cap = daily_cap()
|
|
103
|
+
projected = spent + estimated_cost
|
|
104
|
+
if projected > d_cap:
|
|
105
|
+
return {
|
|
106
|
+
"ok": False,
|
|
107
|
+
"reason": f"daily_cap_exceeded: ${spent:.2f} + ${estimated_cost:.2f} > ${d_cap:.2f}",
|
|
108
|
+
"daily_spend": spent,
|
|
109
|
+
"daily_cap": d_cap,
|
|
110
|
+
"estimated_cost": estimated_cost,
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
"ok": True,
|
|
114
|
+
"reason": "",
|
|
115
|
+
"daily_spend": spent,
|
|
116
|
+
"daily_cap": d_cap,
|
|
117
|
+
"per_item_cap": per_cap,
|
|
118
|
+
"estimated_cost": estimated_cost,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def record_run(actual_cost: float) -> float:
|
|
123
|
+
"""Pass-through for callers that want to declare an actual cost.
|
|
124
|
+
|
|
125
|
+
The actual cost lands in the audit log via the ``cost_estimate``
|
|
126
|
+
field on the record. This helper exists so executor call-sites read
|
|
127
|
+
consistently. Returns the validated, clamped cost.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
v = float(actual_cost)
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
return 0.0
|
|
133
|
+
return max(0.0, v)
|