✨ Gremlins MVP: store, launch, stream, call modules and templates
This commit is contained in:
@@ -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))}
|
||||
Reference in New Issue
Block a user