✨ Gremlins MVP: store, launch, stream, call modules and templates

This commit is contained in:
2026-10-16 23:24:59 +02:00
parent b8affd82e7
commit aff29d61ba
9 changed files with 668 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
"""Stream-json reader and accounting for one `claude -p` call: pure functions, no IO."""
import datetime as _dt
import json
import re
import time
TTL_1H, TTL_5M = 3600, 300
CACHE_HIT_RATIO = 0.9
MU_FIELDS = ("inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens")
TOKEN_KEYS = ("input", "output", "cache_creation", "cache_read", "cache_creation_1h", "cache_creation_5m")
def iso(t: float | None = None) -> str:
"""ISO 8601 with the local offset, from epoch seconds (now if not given)."""
if t is None:
t = time.time()
return _dt.datetime.fromtimestamp(t).astimezone().isoformat(timespec="seconds")
def parse_iso(s) -> float | None:
"""Epoch seconds of an ISO string written by `iso`, or None."""
if not s or not isinstance(s, str):
return None
try:
return _dt.datetime.fromisoformat(s).timestamp()
except ValueError:
return None
class StreamState:
"""What one `claude -p --output-format stream-json --verbose` stream told us so far."""
def __init__(self):
self.init = None # the system/init event
self.session_id = None
self.msg_ids: list = []
self._ids: set = set()
self.first_usage = None # usage of the first assistant event (cache verdict)
self.first_at = None # its arrival time (the base prefix read)
self.last_usage = None # usage of the last assistant event (base context)
self.last_assistant_at = None
self.result = None # the result event
self.refused = None # text of a model-restricted warning
self.events = 0
def feed(self, ev: dict, t: float | None) -> None:
if not isinstance(ev, dict):
return
self.events += 1
ty = ev.get("type")
if ty == "system":
st = ev.get("subtype")
if st == "init" and self.init is None:
self.init = ev
self.session_id = ev.get("session_id") or self.session_id
elif (st == "informational" and ev.get("level") == "warning"
and "is restricted" in str(ev.get("content", ""))):
self.refused = str(ev.get("content"))
elif ty == "assistant":
m = ev.get("message") or {}
mid = m.get("id")
if mid not in self._ids:
self._ids.add(mid)
self.msg_ids.append(mid)
u = m.get("usage") or {}
if self.first_usage is None:
self.first_usage, self.first_at = u, t
self.last_usage, self.last_assistant_at = u, t
elif ty == "result":
self.result = ev
self.session_id = ev.get("session_id") or self.session_id
@property
def turns(self) -> int:
return len(self.msg_ids)
def context_tokens(usage: dict | None) -> int:
"""Prompt-side tokens of one request: input + cache creation + cache read."""
if not usage:
return 0
return (int(usage.get("input_tokens") or 0) + int(usage.get("cache_creation_input_tokens") or 0)
+ int(usage.get("cache_read_input_tokens") or 0))
_API_ERROR_TEXT = ( # checked in this order, after the status code
("prompt_too_long", re.compile(r"prompt is too long|prompt too long|prompt_too_long", re.I)),
("usage_limit", re.compile(r"usage limit|rate[ _-]?limit|hit your limit|api error:?\s*429", re.I)),
("overloaded", re.compile(r"overloaded|api error:?\s*529", re.I)),
("api_error", re.compile(r"api error", re.I)),
)
def api_error_reason(res: dict | None) -> tuple[str, str] | None:
"""(reason, detail) for a result that carries an API error, else None. `reason` is
`usage_limit` (429), `overloaded` (529), `prompt_too_long` or `api_error`."""
if not res:
return None
status = res.get("api_error_status")
try:
status = int(status) if status not in (None, "") else None
except (TypeError, ValueError):
status = None
if not res.get("is_error") and status is None:
return None
parts = [res.get("result"), res.get("error"), res.get("errors")]
text = " | ".join(p if isinstance(p, str) else json.dumps(p) for p in parts if p)
detail = "api_error_status %s: %s" % (status, text[:200] if text else "(no text)")
if status == 429:
return "usage_limit", detail
if status == 529:
return "overloaded", detail
for reason, rx in _API_ERROR_TEXT:
if rx.search(text):
return reason, detail
if status is not None:
return "api_error", detail
return None
def classify(ss: StreamState, exit_code, stopped: bool) -> tuple[str, str | None]:
"""(status, reason) of a finished call; the first matching case wins."""
res = ss.result
match (bool(stopped), ss.refused, res, api_error_reason(res), exit_code):
case (True, _, _, _, _):
return "stopped", "stopped"
case (_, str(), _, _, _):
return "error", "model_refused"
case (_, _, None, _, code):
return "error", "exit_code" if code else "no_result"
case (_, _, _, (reason, _), _):
return "error", reason
case (_, _, dict() as r, _, _) if r.get("is_error"):
return "error", "is_error"
case (_, _, _, _, code) if code:
return "error", "exit_code"
case _:
return "ok", None
def usage_from_result(result: dict, before_mu: dict, cost_before) -> dict:
"""Tokens (with the 1h/5m split) from `result.usage`; `models_used` = modelUsage entries that
grew against the session's totals before the call, largest first; cost delta against the
session's cost before the call. Costs are rounded here, once, at 6 decimals."""
result = result or {}
usage = result.get("usage") or {}
cc = usage.get("cache_creation") or {}
tokens = {
"input": int(usage.get("input_tokens") or 0),
"output": int(usage.get("output_tokens") or 0),
"cache_creation": int(usage.get("cache_creation_input_tokens") or 0),
"cache_read": int(usage.get("cache_read_input_tokens") or 0),
"cache_creation_1h": int(cc.get("ephemeral_1h_input_tokens") or 0),
"cache_creation_5m": int(cc.get("ephemeral_5m_input_tokens") or 0),
}
after = result.get("modelUsage") or {}
before_mu = before_mu or {}
delta = {}
for m, v in after.items():
b = before_mu.get(m) or {}
delta[m] = {f: int((v or {}).get(f) or 0) - int(b.get(f) or 0) for f in MU_FIELDS}
grew = [m for m, d in delta.items() if any(x > 0 for x in d.values())]
models_used = sorted(grew, key=lambda m: (-sum(delta[m].values()), m))
sums = {f: sum(d[f] for d in delta.values()) for f in MU_FIELDS}
mismatch = (sums["inputTokens"], sums["outputTokens"], sums["cacheCreationInputTokens"],
sums["cacheReadInputTokens"]) != (tokens["input"], tokens["output"],
tokens["cache_creation"], tokens["cache_read"])
total = result.get("total_cost_usd")
if total is None:
cost_delta = None
else:
cost_delta = round(float(total) - float(cost_before or 0.0), 6)
total = round(float(total), 6)
return {"tokens": tokens, "models_used": models_used, "mismatch": mismatch,
"cost_delta": cost_delta, "total": total, "model_usage": after}
def cache_verdict(first_usage: dict | None, base_ctx) -> str | None:
"""The first request's cache read against the base's context tokens."""
if first_usage is None:
return None
cr = int(first_usage.get("cache_read_input_tokens") or 0)
if base_ctx and cr >= CACHE_HIT_RATIO * float(base_ctx):
return "hit"
return "partial" if cr > 0 else "miss"
def infer_ttl(tokens: dict | None, prev):
"""The TTL of a base's cache entries from one call's tokens: 1h written -> 3600, only 5m
written -> 300, otherwise (read only, or nothing) `prev`."""
if not tokens:
return prev
if int(tokens.get("cache_creation_1h") or 0) > 0:
return TTL_1H
if int(tokens.get("cache_creation_5m") or 0) > 0:
return TTL_5M
return prev
def wrote_only_5m(tokens: dict | None) -> bool:
"""The call wrote 5-minute cache entries and no 1-hour ones."""
return (bool(tokens) and int(tokens.get("cache_creation_5m") or 0) > 0
and int(tokens.get("cache_creation_1h") or 0) == 0)
def warmth(last_hit_ts: float | None, ttl_s, now: float) -> dict:
"""warm until `margin` before last_hit + ttl, expiring until last_hit + ttl, then cold;
margin = max(120, ttl/10). No hit at all is cold."""
if last_hit_ts is None:
return {"base_state": "cold", "warm_until": None, "ttl_left_s": None}
ttl = float(ttl_s or TTL_1H)
margin = max(120.0, ttl / 10.0)
wu = float(last_hit_ts) + ttl
state = "warm" if now < wu - margin else ("expiring" if now < wu else "cold")
return {"base_state": state, "warm_until": iso(wu), "ttl_left_s": int(max(0.0, wu - now))}