Adaptive budget: pace spend to each capacity pool (re-cut of #167 onto main) #174
No files matched your search
@@ -111,7 +111,8 @@ def board() -> dict:
|
||||
"closed_recently": [{"n": i["number"], "title": i["title"], "closed": i.get("closed_at")} for i in closed[:20]],
|
||||
"prs": [{"n": p["number"], "title": p["title"], "head": p["head"]["ref"], "mergeable": p.get("mergeable")} for p in prs],
|
||||
"andons": [i["number"] for i in open_issues if any(l["name"] == "andon" for l in i.get("labels") or [])],
|
||||
"spend": {"today": round(today, 2), "daily_cap": day_cap, "month": round(month, 2), "monthly_cap": month_cap}}
|
||||
"spend": {"today": round(today, 2), "daily_cap": day_cap, "month": round(month, 2), "monthly_cap": month_cap,
|
||||
"pools": {p["name"]: {"spent": round(tm.pool_spent(p), 2), "allowance": round(tm.allowance(p), 2)} for p in tm.pools()}}}
|
||||
|
||||
|
||||
def log_issue() -> int:
|
||||
@@ -263,7 +264,9 @@ def tick() -> int:
|
||||
created.append(issue["number"])
|
||||
if c.get("queue", c.get("ready", True)) and len(queued) + len(created) <= max_ready:
|
||||
tm.label(issue["number"], "queued") # the heartbeat promotes it when nothing else is in flight
|
||||
lines = [f"🔭 Astra ({seat}), spend ${state['spend']['today']:.2f} of ${state['spend']['daily_cap']:.2f} today:", "", plan["note"], ""]
|
||||
per = ", ".join(f"{k} ${v['spent']:.2f} of ${v['allowance']:.2f}" for k, v in state["spend"]["pools"].items()) \
|
||||
or f"${state['spend']['today']:.2f} of ${state['spend']['daily_cap']:.2f}"
|
||||
lines = [f"🔭 Astra ({seat}), spend today, {per}:", "", plan["note"], ""]
|
||||
if queued:
|
||||
lines.append("Queued: " + ", ".join(f"#{n}" for n in queued))
|
||||
if created:
|
||||
|
||||
@@ -1231,6 +1231,63 @@ assert tm.rank_order() == [], "the ranking is a preference: an unreadable log ne
|
||||
tm.issues, tm.gh, tm.label, tm.unlabel = real80
|
||||
print("ALL OK 80")
|
||||
|
||||
# 82 adaptive budget: each route is paced to its capacity pool; no pool, no change
|
||||
import io, contextlib
|
||||
real_pools = tm.pools
|
||||
day0 = tm._midnight(NOW)
|
||||
def spent_row(usd, pool="anthropic"): return {"at": tm._iso(NOW), "model": "m", "pool": pool, "usd": usd}
|
||||
def fresh82(*rows):
|
||||
tm.LEDGER[:] = list(rows)
|
||||
for back in (0, 40): tm._ledger_files[(NOW - dt.timedelta(days=back)).strftime("%Y-%m")] = ([], None)
|
||||
def refused(route, now=None):
|
||||
try: tm.budget_check(route, now); return False
|
||||
except tm.Budget: return True
|
||||
credit = {"name": "credits", "kind": "expiring", "balance_usd": 30, "as_of": day0.strftime("%Y-%m-%d"),
|
||||
"resets_at": (day0 + dt.timedelta(days=10)).strftime("%Y-%m-%d"), "routes": ["anthropic"]}
|
||||
sub_ = {"name": "sub", "kind": "window", "window_usd": 2, "resets_at": "hours:5", "routes": ["venice"]}
|
||||
tm.pools = lambda: [credit, sub_]
|
||||
os.environ["TIME_DAILY_USD"] = "1" # the global cap is not what a pooled route answers to
|
||||
fresh82()
|
||||
assert tm.allowance(credit, NOW) == 3.0, "$30 over 10 days"
|
||||
fresh82(spent_row(1), spent_row(1)); assert not refused("anthropic")
|
||||
fresh82(spent_row(1), spent_row(1), spent_row(1)); assert refused("anthropic"), "the fourth dollar"
|
||||
nxt = NOW + dt.timedelta(days=1)
|
||||
fresh82(spent_row(3)) # a day passes: yesterday's $3 is spent, $27 over 9 days
|
||||
assert tm.allowance(credit, nxt) == 3.0 and not refused("anthropic", nxt)
|
||||
fresh82(); assert tm.allowance(credit, nxt) == 30 / 9 > 3, "a quiet day raises the share"
|
||||
os.environ["TIME_PACE"] = "0.8"; assert abs(tm.allowance(credit, NOW) - 2.4) < 1e-9; os.environ.pop("TIME_PACE")
|
||||
real_slack = tm.slack; fresh82(spent_row(3.5)); tm.slack = lambda: 1.5
|
||||
assert not refused("anthropic"), "a run under way may go over, against the pool's allowance"
|
||||
fresh82(spent_row(4.5)); assert refused("anthropic"); tm.slack = real_slack
|
||||
# a window pool refuses once the window is spent and allows again after its reset
|
||||
fresh82(spent_row(2, "venice")); assert refused("venice")
|
||||
assert not refused("venice", tm.window_bounds("hours:5", NOW)[1] + dt.timedelta(minutes=1)), "new window"
|
||||
# weekly windows start on their day
|
||||
assert tm.window_bounds("weekly:mon", dt.datetime(2026, 10, 21, 9, tzinfo=dt.timezone.utc))[0] == dt.datetime(2026, 10, 19, tzinfo=dt.timezone.utc)
|
||||
# a route with no pool falls back to TIME_DAILY_USD (the global cap), and the monthly cap stays hard
|
||||
fresh82(spent_row(1.5, "codex")); assert refused("codex") and refused(None)
|
||||
fresh82(spent_row(0.5, "codex")); assert not refused("codex")
|
||||
# booking names the pool, and the doctor and Astra report each pool
|
||||
fresh82(); tm.book("claude-sonnet-5-5", {"prompt_tokens": 10}); tm.book("kimi-k3", {}); assert [r["pool"] for r in tm.LEDGER] == ["anthropic", "venice"]
|
||||
fresh82(spent_row(1)); tm.http = World([]).http; os.environ["CURSOR_API_KEY"] = "ck"
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out): tm.cmd_doctor()
|
||||
assert "pool `credits` | expiring, $30.00; spent $1.00 since reset; next reset " in out.getvalue() and "allowance today $3.00" in out.getvalue(), out.getvalue()
|
||||
assert "pool `sub` | window, $2.00; spent $0.00" in out.getvalue()
|
||||
assert tm.pool_spent(credit) == 1 and tm.pool_spent(sub_) == 0
|
||||
tm.pools = real_pools; os.environ.pop("TIME_DAILY_USD"); tm.LEDGER.clear(); tm._ledger_files.clear()
|
||||
# pools.json is read through the API (the workflows check out only .github/time), once per run
|
||||
import base64 as _b82
|
||||
real_gh82, reads82 = tm.gh, []
|
||||
tm._pools = None
|
||||
tm.gh = lambda m, path, b=None, **k: reads82.append(path) or {"content": _b82.b64encode(b'[{"name": "x", "kind": "balance", "routes": ["venice"]}]').decode()}
|
||||
assert tm.pools()[0]["name"] == "x" and tm.pool_for("venice")["name"] == "x" and reads82 == ["/repos/o/r/contents/time/pools.json"], reads82
|
||||
tm.gh = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("GET x -> 404: not found")); tm._pools = None
|
||||
assert tm.pools() == [] and tm.pool_for("anthropic") is None, "no pools.json: no pools, the daily cap paces"
|
||||
tm.gh = real_gh82; tm._pools = None
|
||||
print("ALL OK 82")
|
||||
|
||||
|
||||
# ---------- venture loop lifecycle: these still run against the old GitHub-shaped fake (Pages API, author_association,
|
||||
# per_page); the Gitea shapes are pinned by 79 above. They run only with TIME_TEST_VENTURE=1 until the fake is ported.
|
||||
if not os.environ.get("TIME_TEST_VENTURE"):
|
||||
|
||||
+106
-12
@@ -4,7 +4,8 @@
|
||||
python3 .github/time/time.py heartbeat # on schedule: plan specs, dispatch `ready` cards, merge approved PRs, pull the next card
|
||||
|
||||
Ledger = this Gitea repo. Brains = Venice (VENICE_API_KEY) or Claude (ANTHROPIC_API_KEY), per seat in .github/time/seats.json.
|
||||
Spend = time/ledger/<YYYY-MM>.jsonl in the repo; TIME_DAILY_USD / TIME_MONTHLY_USD cap it before every call.
|
||||
Spend = time/ledger/<YYYY-MM>.jsonl in the repo; TIME_MONTHLY_USD caps it before every call, and each call is paced to the
|
||||
capacity pool it books to (time/pools.json), or to TIME_DAILY_USD when no pool takes its route.
|
||||
Loop guards: Time's own comments (author gitea-actions) never trigger replies or count as verdicts; the heartbeat
|
||||
has a daily cap and a concurrency group. Unlike GitHub, Gitea does fire events for the Actions token's own writes,
|
||||
so every event-driven workflow filters on the author.
|
||||
@@ -326,7 +327,7 @@ def anthropic_messages(body: dict, timeout: float) -> dict:
|
||||
|
||||
def chat(body: dict, timeout: float) -> dict:
|
||||
"""Every model call goes through here: the budget is checked first and the spend booked after."""
|
||||
budget_check()
|
||||
budget_check(provider_of(body["model"]))
|
||||
if provider_of(body["model"]) == "anthropic":
|
||||
native = (os.environ.get("TIME_ANTHROPIC_API") or "messages").strip().lower() != "compat"
|
||||
out = (anthropic_messages if native else anthropic_chat)(body, timeout)
|
||||
@@ -377,8 +378,8 @@ def book(model: str, usage: dict) -> None:
|
||||
if not LEDGER: # first line: every entry point (heartbeat, mention, venture) flushes without remembering to
|
||||
atexit.register(flush_ledger)
|
||||
usd = round(((i - r - w) * p[0] + r * p[0] * 0.1 + w * p[0] * 1.25 + o * p[1]) / 1e6, 6) if p else None
|
||||
LEDGER.append({"at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "model": model, "in": i, "out": o,
|
||||
"usd": usd, **({"cached": r, "written": w} if r or w else {})})
|
||||
LEDGER.append({"at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "model": model, "pool": provider_of(model),
|
||||
"in": i, "out": o, "usd": usd, **({"cached": r, "written": w} if r or w else {})})
|
||||
|
||||
|
||||
def flush_ledger() -> None:
|
||||
@@ -402,8 +403,8 @@ def spend(prefix: str) -> float:
|
||||
return sum(r.get("usd") or 0 for r in ledger_lines(prefix[:7]) + LEDGER if r.get("at", "").startswith(prefix))
|
||||
|
||||
|
||||
def budget() -> tuple[float, float, float, float]:
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
def budget(now: dt.datetime | None = None) -> tuple[float, float, float, float]:
|
||||
now = now or dt.datetime.now(dt.timezone.utc)
|
||||
return (spend(now.strftime("%Y-%m-%d")), float(os.environ.get("TIME_DAILY_USD") or 5),
|
||||
spend(now.strftime("%Y-%m")), float(os.environ.get("TIME_MONTHLY_USD") or 100))
|
||||
|
||||
@@ -428,13 +429,98 @@ def finishing():
|
||||
_slack.value = old
|
||||
|
||||
|
||||
def budget_check() -> None:
|
||||
today, day_cap, month, month_cap = budget()
|
||||
# Capacity pools (time/pools.json, kept by the operator): [{"name", "kind": "expiring" | "window" | "balance", "routes": ["anthropic"],
|
||||
# "balance_usd" + "as_of" + "resets_at": "2026-11-01" | "window_usd" + "resets_at": "weekly:mon" or "hours:5"}]. A call is paced to
|
||||
# the pool its route books to; with no pool the daily cap applies as before.
|
||||
POOLS_FILE = "time/pools.json"
|
||||
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
_pools: list[dict] | None = None
|
||||
|
||||
|
||||
def pools() -> list[dict]:
|
||||
"""time/pools.json on the default branch, read once per run through the API like the ledger: the workflows check out
|
||||
only .github/time, so a file on disk was invisible to every one of them. Missing or unreachable: no pools, and the
|
||||
daily cap paces as before. Malformed: it fails loudly."""
|
||||
global _pools
|
||||
if _pools is None:
|
||||
try:
|
||||
f = gh("GET", f"/repos/{REPO}/contents/{POOLS_FILE}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
if "-> 404" not in str(e):
|
||||
print(f"pools unreadable, pacing by TIME_DAILY_USD: {str(e)[:120]}")
|
||||
_pools = []
|
||||
return _pools
|
||||
_pools = json.loads(base64.b64decode(f.get("content") or "").decode() or "[]")
|
||||
return _pools
|
||||
|
||||
|
||||
def pool_for(route: str | None) -> dict | None:
|
||||
return next((p for p in pools() if route in p["routes"]), None)
|
||||
|
||||
|
||||
def _iso(t: dt.datetime) -> str:
|
||||
return t.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _midnight(t: dt.datetime) -> dt.datetime:
|
||||
return t.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def window_bounds(cadence: str, now: dt.datetime) -> tuple[dt.datetime, dt.datetime]:
|
||||
"""The refill window holding `now`. shortcut: `hours:N` counts from the epoch, not from a provider's first use; add an anchor if one needs it."""
|
||||
kind, _, arg = cadence.partition(":")
|
||||
if kind == "hours":
|
||||
span = int(arg) * 3600
|
||||
start = dt.datetime.fromtimestamp(now.timestamp() // span * span, dt.timezone.utc)
|
||||
return start, start + dt.timedelta(seconds=span)
|
||||
if kind == "weekly":
|
||||
start = _midnight(now) - dt.timedelta(days=(now.weekday() - DAYS.index(arg[:3].lower())) % 7)
|
||||
return start, start + dt.timedelta(days=7)
|
||||
raise ValueError(f"bad window cadence {cadence!r}: use weekly:mon or hours:5")
|
||||
|
||||
|
||||
def pool_spend(p: dict, since: str, until: str = "9999") -> float:
|
||||
"""Dollars booked to the pool's routes in [since, until) (ISO strings). A row from before the `pool` field is placed by its model."""
|
||||
rows, m, last = list(LEDGER), since[:7], dt.datetime.now(dt.timezone.utc).strftime("%Y-%m")
|
||||
while m <= last:
|
||||
rows += ledger_lines(m)
|
||||
m = f"{int(m[:4]) + (m[5:] == '12')}-{int(m[5:]) % 12 + 1:02d}"
|
||||
return sum(r.get("usd") or 0 for r in rows if since <= r.get("at", "") < until
|
||||
and (r.get("pool") or provider_of(r.get("model", ""))) in p["routes"])
|
||||
|
||||
|
||||
def pool_spent(p: dict, now: dt.datetime | None = None) -> float:
|
||||
"""What counts against allowance(): this window's spend for a window pool, today's for the others."""
|
||||
now = now or dt.datetime.now(dt.timezone.utc)
|
||||
return pool_spend(p, _iso(window_bounds(p["resets_at"], now)[0] if p["kind"] == "window" else _midnight(now)))
|
||||
|
||||
|
||||
def allowance(p: dict, now: dt.datetime | None = None) -> float:
|
||||
"""Dollars the pool may spend today (a window pool: in the whole window). Expiring credit is spread over the days left
|
||||
before it lapses, so a quiet day raises tomorrow's share: (balance - spent before today) / days to reset x TIME_PACE."""
|
||||
now = now or dt.datetime.now(dt.timezone.utc)
|
||||
if p["kind"] == "window":
|
||||
return p["window_usd"]
|
||||
if p["kind"] == "expiring":
|
||||
day = _midnight(now)
|
||||
left = p["balance_usd"] - pool_spend(p, p["as_of"], _iso(day))
|
||||
days = max(1, (dt.date.fromisoformat(p["resets_at"]) - day.date()).days)
|
||||
return max(0.0, left) / days * float(os.environ.get("TIME_PACE") or 1)
|
||||
return float(os.environ.get("TIME_DAILY_USD") or 5)
|
||||
|
||||
|
||||
def budget_check(route: str | None = None, now: dt.datetime | None = None) -> None:
|
||||
today, day_cap, month, month_cap = budget(now)
|
||||
if month >= month_cap:
|
||||
raise Budget(f"this month's spend is ${month:.2f} of the ${month_cap:.2f} cap; Time is back in dry-run until a human raises it")
|
||||
if today >= day_cap * slack():
|
||||
allowance = f" (a run under way may go to ${day_cap * slack():.2f})" if slack() > 1 else ""
|
||||
raise Budget(f"today's spend is ${today:.2f} of the ${day_cap:.2f} cap{allowance}; carrying on tomorrow")
|
||||
if (p := pool_for(route)):
|
||||
spent, limit = pool_spent(p, now), allowance(p, now)
|
||||
if spent >= limit * slack():
|
||||
more = f" (a run under way may go to ${limit * slack():.2f})" if slack() > 1 else ""
|
||||
raise Budget(f"pool {p['name']} has spent ${spent:.2f} of its ${limit:.2f} allowance{more}; carrying on when it refills")
|
||||
elif today >= day_cap * slack():
|
||||
more = f" (a run under way may go to ${day_cap * slack():.2f})" if slack() > 1 else ""
|
||||
raise Budget(f"today's spend is ${today:.2f} of the ${day_cap:.2f} cap{more}; carrying on tomorrow")
|
||||
|
||||
|
||||
def andon(reason: str) -> None:
|
||||
@@ -1468,7 +1554,7 @@ def tick() -> None:
|
||||
print(f"#{n}: daily cap reached")
|
||||
continue
|
||||
# out of budget: skip like the launches cap, but through the Budget handler so the card says why
|
||||
budget_check() # and before the "working on this" beat, so a capped card never looks dispatched
|
||||
budget_check(provider_of(worker_seat_model(worker)[1]) if seat_worker(worker) else None) # and before the "working on this" beat, so a capped card never looks dispatched
|
||||
rev = revision["pr"] if revision else None
|
||||
if seat_worker(worker):
|
||||
prov = provider_of(worker_seat_model(worker)[1])
|
||||
@@ -1573,6 +1659,14 @@ def cmd_doctor() -> int:
|
||||
row("TIME_CAP_SLACK", True, os.environ.get("TIME_CAP_SLACK") or "1.5 (default)")
|
||||
row("TIME_MONTHLY_USD", True, os.environ.get("TIME_MONTHLY_USD") or "100 (default)")
|
||||
row("spend", today < day_cap and month < month_cap, f"${today:.2f} today, ${month:.2f} this month ({ledger_path(dt.datetime.now(dt.timezone.utc).strftime('%Y-%m'))})")
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
for p in pools():
|
||||
win = p["kind"] == "window"
|
||||
reset = window_bounds(p["resets_at"], now)[1].strftime("%Y-%m-%d %H:%MZ") if win else p.get("resets_at", "none")
|
||||
since = pool_spent(p, now) if win else pool_spend(p, p.get("as_of") or now.strftime("%Y-%m-01"))
|
||||
limit = allowance(p, now)
|
||||
row(f"pool `{p['name']}`", pool_spent(p, now) < limit,
|
||||
f"{p['kind']}, ${p.get('balance_usd', p.get('window_usd', 0)):.2f}; spent ${since:.2f} since reset; next reset {reset}; allowance today ${limit:.2f}")
|
||||
row("(optional) ANTHROPIC_API_KEY (Claude seats)", bool(os.environ.get("ANTHROPIC_API_KEY")),
|
||||
"set" if os.environ.get("ANTHROPIC_API_KEY") else "not set")
|
||||
row("TIME_ANTHROPIC_API", True, os.environ.get("TIME_ANTHROPIC_API") or "messages (default, with prompt caching; compat = the OpenAI-compatible endpoint)")
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- The second fix round escalates to a different model from the first round: `claude:opus` with the Claude seats (it was `venice:kimi code`, and grok 4.7 before that). `TIME_VENTURE_ESCALATE` overrides it.
|
||||
|
||||
### Added
|
||||
- Adaptive budget: spend is paced to each capacity pool instead of one fixed daily cap. `time/pools.json` (kept by the operator) lists the pools: `expiring` credit allows `(balance - spent since as_of) / days to resets_at x TIME_PACE` a day (default 1.0), so a quiet day raises tomorrow's share; a `window` pool (`weekly:mon`, `hours:5`) allows `window_usd` per window and refills at its reset; a `balance` pool keeps `TIME_DAILY_USD`. A call is checked against the pool its provider books to, and `TIME_DAILY_USD` is the fallback for a route with no pool; the monthly cap stays global. Ledger rows carry a `pool` field, `doctor` prints each pool's balance, spend, next reset and today's allowance, and Astra's log line reports spend per pool. It ships with an empty `pools.json`, so nothing changes until the operator fills it in. `pools.json` is read through the API once per run, like the ledger, since every workflow checks out only `.github/time`.
|
||||
- The router (time#68, #72; `router.py`, `time/router.json`): the factory picks its own next work across ventures, features, refinements and optimizations. Astra's seat no longer picks cards in prose; it judges every candidate (each open card with a `DONE:` line that isn't a parent, a human's or the venture loop's own, plus "start a new venture") with a kind, a value 1–5 and a size S/M/L, and code ranks them: `weight[kind] × restless(kind) × age × (value + 0.5 × cards waiting on it) / size`. The weights are the owner's dial per kind (all 1, balanced); a kind not picked lately climbs (`restless`, 0.05 a day for up to 10 days) so none starves; an L is ranked but never queued (split it first). The Astra log shows the ranked table. `mode: dry` posts it and touches nothing; `live` queues from the top while the day's money left covers each card's estimated cost (`cost_usd`), writes the seat's new cards unqueued (candidates the next day), and the heartbeat's `promote_next` promotes queued cards in the live ranking's order instead of oldest first. `off`, or no file, is the planner as before. It starts `dry`. The policy is one function, so a bandit policy (Beth, Jev, Fable's maths) can replace it. Astra also reads the last 14 days of its log instead of the thread's first twenty comments.
|
||||
- A signup's Venture field matches the venture's slug, name, home folder or an old slug, ignoring case, spaces and punctuation. The field is typed by a person (Teahub does not prefill it from the link), who writes "Cartridge" or "Loot Drop", not `box-art-night`; those signups were not counted.
|
||||
- A worker's tool call that is cut off at the output limit, or arrives with malformed arguments, is an error the worker reads on its next step instead of a crash that throws the run away. The first Claude venture builds (time#148, #149) died on `'content'`: a whole launch page written in one `write_file` ran past the output limit and arrived with its path and no content. The error says so and asks for smaller files.
|
||||
|
||||
@@ -8,6 +8,7 @@ Time keeps a record of what it spends on models. This folder holds it.
|
||||
- `model`: the model that was called.
|
||||
- `in`: input (prompt) tokens.
|
||||
- `out`: output (completion) tokens.
|
||||
- `pool`: the provider the call booked to (`anthropic`, `venice`); `time/pools.json` maps it to a capacity pool.
|
||||
- `usd`: the cost in US dollars, or `null` if the seat has no price (unpriced seats).
|
||||
|
||||
Example line:
|
||||
@@ -28,3 +29,15 @@ Time stops calling models. The next time the heartbeat has a card to run, it ope
|
||||
## Don't edit it by hand
|
||||
|
||||
Humans should never edit these files. The caps rely on them being exact, and Time only ever appends. A hand edit can hide spend or break the next append.
|
||||
|
||||
## Pools (`time/pools.json`)
|
||||
|
||||
The operator keeps one entry per capacity pool; a route with no entry is paced by `TIME_DAILY_USD`. Time reads the file through the API once per run. Fields: `name`, `kind`, `routes`, and by kind:
|
||||
|
||||
- `expiring`: `balance_usd` (as of `as_of`, a date), `resets_at` (the date the credit lapses). Allows `(balance - spent since as_of) / days to resets_at x TIME_PACE` a day.
|
||||
- `window`: `window_usd`, `resets_at` as `weekly:mon` or `hours:5`. Allows `window_usd` per window.
|
||||
- `balance`: `balance_usd`. Allows `TIME_DAILY_USD` a day.
|
||||
|
||||
```json
|
||||
[{"name": "anthropic-credits", "kind": "expiring", "balance_usd": 30, "as_of": "2026-10-19", "resets_at": "2026-10-29", "routes": ["anthropic"]}]
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
Reference in new issue
Block a user