feat(seats): an API seat's cycle starts on its own grant day
All checks were successful
Flatpak / check (push) Has been skipped
Flatpak / publish (push) Successful in 5m30s

The ledger reset every API seat's spend on one
REVIEW_GATES_API_CYCLE_DAY and cleared every credit-out, an OpenRouter
key's too, at that reset. Each API seat's cycle starts on its
ANTHROPIC_API_KEY<n>_GRANT_DAY now (1..31, a day past a month's end its
last), else the cycle day, also 1..31. A credit-out carries across the
rollover, and the run's probe tries a key held out on credit again, as
`seats` does an API key: an ok lifts the mark - an API key's is new
credits, its cycle restarting from the grant day, or from today when
that day is over a week back; an OpenRouter key keeps its cycle's spend,
and a key that answers with its cycle's budget spent stays out on that
budget. A probe that finds a key out of credit records it, unless the
ledger already has it out. Both are decided in one locked update on the
file's entry, a mark another driver made since the probe started
standing, and the ledger's stamps carry their UTC offset so marks are
ordered by instant.

`seats` shows the probe's reading beside the driver's verdict (the
`probe` and `driver` columns replace `state` and `ledger: out`), each
seat's grant day and when its next credits are due, and notes on what
the probe changed in the ledger or why the driver's verdict stands; the
run logs the days.
This commit is contained in:
Jorijn van der Graaf 2026-11-02 13:58:29 +01:00
commit ceb87f70ef
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
12 changed files with 774 additions and 83 deletions

View file

@ -534,7 +534,8 @@ inventory unit, against a frozen commit range.)";
constexpr std::string_view Prog = "crafter-review seats";
const Parsed parsed = ParseOptions(args, SeatsOptions);
if (parsed.help) {
std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 from the environment and/or the dotenv file. Never prints a token.\n",
std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 and ANTHROPIC_API_KEY1..6 from the environment and/or the dotenv file. Never prints a token. An API key's "
"probe is recorded in the seat ledger: new credits, or the credit error.\n",
Help(Prog, "How much usage each Claude seat has left: every seat probed, both windows and their resets.", SeatsOptions));
return 0;
}
@ -603,9 +604,23 @@ inventory unit, against a frozen commit range.)";
Seats::ProbeOptions api = haiku;
api.api = true;
apiSeats = ProbeAll(todo, api);
// The driver's ledger: what it spent on each key this cycle.
SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig);
for (Json& s : apiSeats) s["ledger"] = ledger.Entry(std::format("api{}", s.at("seat").get<std::int64_t>()));
// The driver's ledger: what it spent on each key this cycle,
// from each seat's grant day. It follows the probe: a key that
// answers again after the credit error is new credits, the
// credit error on a key it has as fine is recorded.
std::map<std::string, std::int32_t> days;
for (const auto& [n, day] : Seats::ApiGrantDays(env)) days[std::format("api{}", n)] = day;
SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig, days);
for (Json& s : apiSeats) {
const std::string id = std::format("api{}", s.at("seat").get<std::int64_t>());
s["grantDay"] = ledger.Day(id);
// `now`, taken before the probes started, is their start;
// what Reconcile writes is stamped when it is written.
s["event"] = ledger.Reconcile(id, s.at("state"), now);
s["ledger"] = ledger.Entry(id, now);
s["ledgerOut"] = SeatPool::LedgerOut(s.at("ledger"), apiConfig.at("budgetUsd").get<double>());
s["nextCredits"] = SeatPool::NextGrant(now, ledger.Day(id), Dyn::Get(s.at("ledger"), "outSince"));
}
}
// The board: the drivers running now and what they hold on each
// account - read, never written.
@ -631,7 +646,7 @@ inventory unit, against a frozen commit range.)";
for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get<std::int64_t>()));
std::println("seat pool: {} seat(s) ({}) from {}, probed {} {}, claude: {}\n", ids.size(), Dyn::Join(ids, ", "), source, Seats::Clock(now, zone), zone.label, claudeBin);
if (!seats.empty()) std::println("{}", Seats::RenderStatusReport(seats, cfg, now, zone, board, keys));
if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, board, apiKeys));
if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, now, board, apiKeys));
std::println("\n{}", Seats::RenderDrivers(board, now, zone));
// The pids above are no use from inside the sandbox: the way to
// stop a run instead.

View file

@ -767,7 +767,7 @@ namespace Crafter::Review::Run {
std::ranges::sort(sorted);
Die(std::format("OpenRouter seats need OpenRouter model ids (vendor/model): --models opus=...,fable=... (now {})", Dyn::Join(sorted, ", ")));
}
if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log);
if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log, {}, ledger.get());
if (board) {
// Our readings onto the board (a probed-ok seat clears a verdict
// another driver left), theirs into our seats.
@ -793,7 +793,12 @@ namespace Crafter::Review::Run {
for (const Json& s : seats) names.push_back(std::format("{} ({})", Dyn::Str(s.at("id")), Dyn::Str(s.at("kind"))));
std::string line = std::format("seats: {} from {}", Dyn::Join(names, ", "), envFile && !envFile->empty() ? *envFile : std::string("this machine"));
if (std::ranges::any_of(seats, [](const Json& s) { return s.at("kind") == "api"; })) {
line += std::format("; {}, budget ${:g} per api seat per cycle (ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), ledger->Path().string());
std::vector<std::string> days;
for (const Json& s : seats) {
if (s.at("kind") == "api") days.push_back(std::format("{} {}", Dyn::Str(s.at("id")), Dyn::Str(Dyn::Get(s, "grantDay"))));
}
line += std::format("; {}, budget ${:g} per api seat per cycle from its grant day ({}; ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), Dyn::Join(days, ", "),
ledger->Path().string());
}
if (openrouter) {
line += std::format("; budget ${:g} per OpenRouter seat per cycle, {} concurrent (ledger {})", Number(apiCfg.at("orBudgetUsd")), Dyn::Str(apiCfg.at("orConcurrency")), ledger->Path().string());

View file

@ -206,7 +206,8 @@ namespace Crafter::Review::SeatPool {
{"budgetUsd", num("REVIEW_GATES_API_BUDGET_USD", 200.0)},
{"lensCostUsd", num("REVIEW_GATES_API_LENS_COST_USD", 1.0)},
{"concurrency", Int(num("REVIEW_GATES_API_CONCURRENCY", 8))},
{"cycleDay", std::max<std::int64_t>(1, std::min<std::int64_t>(28, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))},
// A day past a month's end is its last day (CycleStart).
{"cycleDay", std::max<std::int64_t>(1, std::min<std::int64_t>(31, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))},
{"order", EnvOf(env, "REVIEW_GATES_SEAT_ORDER") == "oauth-first" ? "oauth-first" : "api-first"},
// OpenRouter seats: a prepaid key, cheap models - a smaller reserve
// per running process.
@ -233,47 +234,153 @@ namespace Crafter::Review::SeatPool {
return root / "seat-ledger.json";
}
namespace {
std::chrono::year_month_day LocalDate(double now) {
using namespace std::chrono;
const std::string today = Platform::LocalTime("%Y-%m-%d", now);
return {year(std::stoi(today.substr(0, 4))), month(static_cast<unsigned>(std::stoi(today.substr(5, 2)))), day(static_cast<unsigned>(std::stoi(today.substr(8, 2))))};
}
std::chrono::year_month_day ParseIso(std::string_view iso) {
using namespace std::chrono;
const std::string text(iso);
return {year(std::stoi(text.substr(0, 4))), month(static_cast<unsigned>(std::stoi(text.substr(5, 2)))), day(static_cast<unsigned>(std::stoi(text.substr(8, 2))))};
}
std::string Iso(const std::chrono::year_month_day& d) {
return std::format("{:04d}-{:02d}-{:02d}", static_cast<std::int32_t>(d.year()), static_cast<std::uint32_t>(d.month()), static_cast<std::uint32_t>(d.day()));
}
// A ledger stamp: local time with its UTC offset,
// 2026-10-25T02:00:05+01:00, so any driver orders it by instant across
// the hour that repeats when DST ends, or from another zone.
std::string Stamp(double at) {
std::string text = Platform::LocalTime("%Y-%m-%dT%H:%M:%S%z", at);
if (text.size() == 24) text.insert(22, ":");
return text;
}
// A stamp's instant, epoch seconds: by its offset, else (a stamp
// written before 1.1.0) as local time; none when it cannot be read.
std::optional<double> Instant(std::string_view stamp) {
auto number = [&](std::size_t from, std::size_t width) -> std::optional<std::int32_t> {
if (from + width > stamp.size()) return std::nullopt;
std::int32_t value = 0;
for (std::size_t i = from; i < from + width; ++i) {
if (stamp[i] < '0' || stamp[i] > '9') return std::nullopt;
value = value * 10 + (stamp[i] - '0');
}
return value;
};
if (stamp.size() < 19 || stamp[4] != '-' || stamp[7] != '-' || stamp[10] != 'T' || stamp[13] != ':' || stamp[16] != ':') return std::nullopt;
const std::optional<std::int32_t> y = number(0, 4), mo = number(5, 2), d = number(8, 2), h = number(11, 2), mi = number(14, 2), s = number(17, 2);
if (!y || !mo || !d || !h || !mi || !s) return std::nullopt;
const double clock = *h * 3600.0 + *mi * 60.0 + *s;
if (stamp.size() == 25 && (stamp[19] == '+' || stamp[19] == '-') && stamp[22] == ':') {
const std::optional<std::int32_t> oh = number(20, 2), om = number(23, 2);
const std::chrono::year_month_day date {std::chrono::year(*y), std::chrono::month(static_cast<unsigned>(*mo)), std::chrono::day(static_cast<unsigned>(*d))};
if (!oh || !om || !date.ok()) return std::nullopt;
const double offset = (stamp[19] == '-' ? -1.0 : 1.0) * (*oh * 3600.0 + *om * 60.0);
return static_cast<double>(std::chrono::sys_days(date).time_since_epoch().count()) * 86400.0 + clock - offset;
}
if (stamp.size() != 19) return std::nullopt;
std::tm local {};
local.tm_year = *y - 1900;
local.tm_mon = *mo - 1;
local.tm_mday = *d;
local.tm_hour = *h;
local.tm_min = *mi;
local.tm_sec = *s;
local.tm_isdst = -1;
const std::time_t seconds = std::mktime(&local);
if (seconds == static_cast<std::time_t>(-1)) return std::nullopt;
return static_cast<double>(seconds);
}
// That day of the month, a day past its end its last day.
std::chrono::year_month_day OnDay(const std::chrono::year_month& ym, std::int32_t day) {
using namespace std::chrono;
const auto last = static_cast<std::int32_t>(static_cast<unsigned>(year_month_day_last(ym.year(), month_day_last(ym.month())).day()));
return {ym.year(), ym.month(), std::chrono::day(static_cast<unsigned>(std::clamp(day, 1, last)))};
}
}
std::string CycleStart(double now, std::int32_t day) {
using namespace std::chrono;
const std::string today = Platform::LocalTime("%Y-%m-%d", now);
const year_month_day date(year(std::stoi(today.substr(0, 4))), month(static_cast<unsigned>(std::stoi(today.substr(5, 2)))),
std::chrono::day(static_cast<unsigned>(std::stoi(today.substr(8, 2)))));
auto iso = [](const year_month_day& d) {
return std::format("{:04d}-{:02d}-{:02d}", static_cast<std::int32_t>(d.year()), static_cast<std::uint32_t>(d.month()), static_cast<std::uint32_t>(d.day()));
};
if (static_cast<std::int32_t>(static_cast<std::uint32_t>(date.day())) >= day) {
return iso(year_month_day(date.year(), date.month(), std::chrono::day(static_cast<std::uint32_t>(day))));
}
const year_month_day previous(sys_days(year_month_day(date.year(), date.month(), std::chrono::day(1))) - days(1));
const auto last = static_cast<std::int32_t>(static_cast<std::uint32_t>(previous.day()));
return iso(year_month_day(previous.year(), previous.month(), std::chrono::day(static_cast<std::uint32_t>(std::min(day, last)))));
const year_month_day date = LocalDate(now);
const year_month ym(date.year(), date.month());
const year_month_day thisMonth = OnDay(ym, day);
return Iso(date >= thisMonth ? thisMonth : OnDay(ym - months(1), day));
}
std::string NextGrant(double now, std::int32_t day, const Json& outSince) {
using namespace std::chrono;
const std::string cycle = CycleStart(now, day);
if (Dyn::Truthy(outSince) && Dyn::Slice(Dyn::Str(outSince), 0, 10) < cycle) return cycle;
const year_month_day start = ParseIso(cycle);
return Iso(OnDay(year_month(start.year(), start.month()) + months(1), day));
}
std::pair<bool, std::string> LedgerVerdict(const Json& entry, double budget) {
const Json& outSince = Dyn::Get(entry, "outSince");
if (Dyn::Truthy(outSince)) return {true, std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(entry, "outReason"), "out"), Dyn::Slice(Dyn::Str(outSince), 0, 16))};
const double spent = Num(Dyn::Get(entry, "spentUsd"));
if (spent >= budget) return {true, std::format("ledger: ${:.2f} spent this cycle, budget ${}", spent, G(budget))};
return {false, std::string()};
}
Json LedgerOut(const Json& entry, double budget) {
if (!LedgerVerdict(entry, budget).first) return nullptr;
return Dyn::Truthy(Dyn::Get(entry, "outSince")) ? "credit" : "budget";
}
// ---- the ledger -------------------------------------------------------------------
Ledger::Ledger(fs::path path, Json apiConfig, Json data) : path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()) {}
Ledger::Ledger(fs::path path, Json apiConfig, Json data, std::map<std::string, std::int32_t> days)
: path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()), days(std::move(days)) {}
Ledger Ledger::Load(const fs::path& path, const Json& apiConfig) {
Ledger Ledger::Load(const fs::path& path, const Json& apiConfig, std::map<std::string, std::int32_t> days) {
Json data;
try {
data = Dyn::LoadJsonFile(path);
} catch (const std::exception&) {
data = Json::object();
}
return Ledger(path, apiConfig, data.is_object() ? data : Json::object());
return Ledger(path, apiConfig, data.is_object() ? data : Json::object(), std::move(days));
}
std::int32_t Ledger::Day(const std::string& seatId) const {
const auto found = days.find(seatId);
if (found != days.end() && found->second > 0) return found->second;
return static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay"))));
}
void Ledger::SetDay(const std::string& seatId, std::int32_t day) {
days[seatId] = day;
}
Json& Ledger::Entry(const std::string& seatId, std::optional<double> now) {
const std::string cycle = CycleStart(NowOr(now), static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))));
const std::string cycle = CycleStart(NowOr(now), Day(seatId));
const Json& e = Dyn::Get(data, seatId);
// Only a newer cycle starts over: an entry of a later one (another
// driver's cycle day) is not wiped.
if (!Dyn::Truthy(e) || cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) {
if (!Dyn::Truthy(e)) {
data[seatId] = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}};
} else if (cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) {
// Only a newer cycle starts over: an entry of a later one (another
// driver's cycle day, or a grant seen early) is not wiped. A
// credit-out carries: the cycle's day is a configured date, the
// credit error was observed (2026-10-17: the calendar cycle held
// api3 out for the first week of its new credits; an OpenRouter
// key's turn puts nothing on it), and only a probe that sees the
// key answer again lifts it (Reconcile). When the key was last seen
// answering again carries too: Reconcile records no credit error
// read before it.
Json next = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", Dyn::Get(e, "outSince")}, {"outReason", Dyn::Get(e, "outReason")}};
if (Dyn::Truthy(Dyn::Get(e, "grantSeen"))) next["grantSeen"] = Dyn::Get(e, "grantSeen");
data[seatId] = std::move(next);
}
return data[seatId];
}
Json Ledger::Update(const std::string& seatId, const std::function<void(Json&)>& change, std::optional<double> now) {
Json Ledger::Update(const std::string& seatId, const std::function<bool(Json&)>& change, std::optional<double> now) {
// The file is the record: re-read under its lock, this process only
// ever adds to it (2026-10-15: each driver saved the copy it had loaded
// and the last writer won - api4 reached the credit error with $157.54
@ -285,10 +392,10 @@ namespace Crafter::Review::SeatPool {
for (auto it = file.begin(); it != file.end(); ++it) merged[it.key()] = it.value();
data = std::move(merged);
Json& e = Entry(seatId, now);
change(e);
const bool write = change(e);
entry = e;
file = data;
return true;
if (write) file = data;
return write;
}, data, 0600, {});
return entry;
}
@ -297,16 +404,70 @@ namespace Crafter::Review::SeatPool {
return Update(seatId, [&](Json& e) {
e["spentUsd"] = Dyn::Round(AsDouble(Dyn::At(e, "spentUsd")) + usd, 4);
e["procs"] = Dyn::Add(Dyn::At(e, "procs"), Json(1));
return true;
}, now);
}
Json Ledger::MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now) {
return Update(seatId, [&](Json& e) {
e["outSince"] = Platform::LocalTime("%Y-%m-%dT%H:%M:%S", NowOr(now));
e["outSince"] = Stamp(NowOr(now));
e["outReason"] = Dyn::Slice(reason, 0, 160);
return true;
}, now);
}
Json Ledger::GrantEntry(const std::string& seatId, double at) const {
using namespace std::chrono;
const std::string cycle = CycleStart(at, Day(seatId));
const std::string weekAgo = Iso(year_month_day(sys_days(LocalDate(at)) - std::chrono::days(7)));
const std::string start = cycle >= weekAgo ? cycle : Iso(LocalDate(at));
return {{"cycle", start}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}, {"grantSeen", Stamp(at)}};
}
std::string Ledger::Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional<double> now) {
if (state != "ok" && state != "out") return {};
const double at = NowOr(now);
// A mark made before the probe started, by instant to the second the
// stamps hold; one made since may postdate what the probe read, and
// one that cannot be read counts as made since.
const double started = std::floor(probedAt);
auto before = [&](const Json& stamp) {
const std::optional<double> instant = Instant(Dyn::Str(stamp));
return instant && *instant < started;
};
const Json fresh = GrantEntry(seatId, at);
std::string event;
// Decided on the file's entry under the lock, not on this process's
// copy: another driver may have recorded the grant and spent on it,
// or the credit error, since this copy was read.
// An OpenRouter key (or<n>) is prepaid: its lift is no grant, no new
// cycle - the mark goes, the cycle's spend stays.
const bool openRouter = seatId.starts_with("or");
Update(seatId, [&](Json& e) {
const Json& outSince = Dyn::Get(e, "outSince");
if (state == "ok") {
if (!Dyn::Truthy(outSince) || !before(outSince)) return false;
if (openRouter) {
e["outSince"] = nullptr;
e["outReason"] = nullptr;
e["grantSeen"] = Stamp(at);
event = "credit-back";
} else {
e = fresh;
event = "new-grant";
}
return true;
}
const Json& seen = Dyn::Get(e, "grantSeen");
if (Dyn::Truthy(outSince) || (Dyn::Truthy(seen) && !before(seen))) return false;
e["outSince"] = Stamp(at);
e["outReason"] = openRouter ? "key limit reached" : "credit balance too low";
event = "credit-gone";
return true;
}, at);
return event;
}
void Ledger::Save() {
try {
Platform::WriteAtomic(path, DumpFile(data), 0600);
@ -319,6 +480,10 @@ namespace Crafter::Review::SeatPool {
return data;
}
const Json& Ledger::ApiConfig() const {
return apiConfig;
}
const fs::path& Ledger::Path() const {
return path;
}
@ -436,7 +601,7 @@ namespace Crafter::Review::SeatPool {
return env;
}
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view specText, Ledger* ledger, const Json& apiConfig) {
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view specText, Ledger* ledger, const Json& apiConfig, std::optional<double> now) {
const std::map<std::string, std::string>& merged = env;
std::vector<std::pair<std::string, Json>> pool;
for (const auto& [n, key] : Seats::ApiKeysFromEnv(merged)) pool.emplace_back(std::format("api{}", n), NewSeat(std::format("api{}", n), "api", key));
@ -482,18 +647,31 @@ namespace Crafter::Review::SeatPool {
if (std::ranges::any_of(seats, IsOpenRouter) && !std::ranges::all_of(seats, IsOpenRouter)) {
throw std::invalid_argument("--seats: an OpenRouter seat cannot share a pool with Anthropic seats or the local login - a model name means a different model per endpoint");
}
// An API seat's cycle starts on its grant day (2026-10-17): the day
// its organization receives the plan's credits, the Console's
// Promotional-credits expiry minus three days.
const std::map<std::int32_t, std::int32_t> grantDays = Seats::ApiGrantDays(env);
for (Json& s : seats) {
if (Kind(s) != "api") continue;
const auto found = grantDays.find(static_cast<std::int32_t>(Dyn::ParseInt(Dyn::Slice(Id(s), 3)).value_or(0)));
const std::int32_t day = found != grantDays.end() ? found->second : (Dyn::Truthy(apiConfig) ? static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))) : 1);
s["grantDay"] = day;
if (ledger) ledger->SetDay(Id(s), day);
}
if (ledger) {
const double now = Platform::Now();
const double at = NowOr(now);
for (Json& s : seats) {
if (!IsPaid(s)) continue;
const Json& e = ledger->Entry(Id(s), now);
const Json& e = ledger->Entry(Id(s), at);
s["spentUsd"] = AsDouble(Dyn::At(e, "spentUsd"));
if (Dyn::Truthy(Dyn::Get(e, "outSince"))) {
const double budget = Dyn::Truthy(apiConfig) ? Budget(s, apiConfig) : std::numeric_limits<double>::infinity();
const auto [out, why] = LedgerVerdict(e, budget);
if (out) {
s["state"] = "out";
s["error"] = std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(e, "outReason"), "out"), Dyn::Slice(Dyn::Str(e.at("outSince")), 0, 16));
} else if (Dyn::Truthy(apiConfig) && AsDouble(s.at("spentUsd")) >= Budget(s, apiConfig)) {
s["state"] = "out";
s["error"] = std::format("ledger: ${:.2f} spent this cycle, budget ${}", AsDouble(s.at("spentUsd")), G(Budget(s, apiConfig)));
s["error"] = why;
// Why the ledger holds the seat out: a credit-out is
// probed again, a spent budget is not.
s["ledgerOut"] = LedgerOut(e, budget);
}
}
}
@ -541,7 +719,62 @@ namespace Crafter::Review::SeatPool {
return std::format("{}: {}{}", Dyn::Str(s.at("state")), Dyn::Join(parts, ", "), Dyn::Truthy(binding) ? std::format(" (binding {})", Dyn::Str(binding)) : std::string());
}
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models, const Log& log, const ProbeFunction& probe) {
namespace {
// A paid key after its probe (started at probedAt): the seat takes
// the spend the file holds, every driver's; a lift puts a held-out
// seat back (an API key's with its cycle restarted), a credit error
// recorded since the probe started stands over its answer, the credit
// error takes it out (an API key's with the ledger's record and the
// next credits' date in the reason), an unreadable probe leaves a
// held-out seat held out.
void AfterPaidProbe(Json& s, const Json& held, Ledger* ledger, double probedAt, double now, const Log& log) {
const std::string event = ledger ? ledger->Reconcile(Id(s), s.at("state"), probedAt, now) : std::string();
const Json entry = ledger ? ledger->Entry(Id(s), now) : Json::object();
if (ledger) s["spentUsd"] = Num(Dyn::Get(entry, "spentUsd"));
if (event == "new-grant") {
s["ledgerOut"] = nullptr;
s["error"] = nullptr;
log(std::format("seat {}: the key answers again - new credits; the ledger cycle restarts (from {})", Id(s), Dyn::Str(entry.at("cycle"))));
} else if (event == "credit-back") {
s["ledgerOut"] = nullptr;
s["error"] = nullptr;
log(std::format("seat {}: the key's limit has room again - back, its cycle's spend kept", Id(s)));
} else if (s.at("state") == "ok" && Dyn::Truthy(Dyn::Get(entry, "outSince"))) {
s["state"] = "out";
s["ledgerOut"] = "credit";
s["error"] = LedgerVerdict(entry, std::numeric_limits<double>::infinity()).second;
} else if (s.at("state") == "ok") {
s["ledgerOut"] = nullptr;
} else if (s.at("state") == "out" && IsOpenRouter(s)) {
// The probe's error says why.
s["ledgerOut"] = "credit";
} else if (s.at("state") == "out") {
const Json e = ledger ? ledger->Entry(Id(s), now) : Json::object();
const Json& outSince = Dyn::Get(e, "outSince");
const Json& day = Dyn::Get(s, "grantDay");
s["ledgerOut"] = "credit";
s["error"] = std::format("credit balance too low{}; next credits due {}", Dyn::Truthy(outSince) ? std::format(" since {}", Dyn::Slice(Dyn::Str(outSince), 0, 16)) : std::string(),
NextGrant(now, Dyn::Truthy(day) ? static_cast<std::int32_t>(Int(AsDouble(day))) : 1, outSince));
} else if (s.at("state") == "unknown" && Dyn::Truthy(held)) {
s["state"] = "out";
s["error"] = std::format("{} (probe unreadable: {})", Dyn::Str(held), Dyn::Str(Dyn::Get(s, "error")));
}
// A key that answers with its cycle's budget spent is out on that
// budget, as LoadSeats holds such a seat out: an OpenRouter key's
// lift keeps its spend, and another driver may have spent on a
// grant it saw. Left ok, the seat would be neither placed nor out.
if (ledger && s.at("state") == "ok") {
if (const auto [out, why] = LedgerVerdict(entry, Budget(s, ledger->ApiConfig())); out) {
s["state"] = "out";
s["ledgerOut"] = "budget";
s["error"] = why;
}
}
}
}
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models, const Log& log, const ProbeFunction& probe, Ledger* ledger,
std::optional<double> now, const OpenRouterProbeFunction& probeKey) {
const ProbeFunction run = probe ? probe : ProbeFunction([](std::int32_t seat, const std::string& token, const std::string& model, double t, const std::string& bin, bool local, bool api) {
return Seats::ProbeSeat(seat, token, {.model = model, .timeoutS = t, .claudeBin = bin, .local = local, .api = api});
});
@ -555,7 +788,7 @@ namespace Crafter::Review::SeatPool {
break;
}
}
if (std::ranges::any_of(seats, IsOpenRouter)) {
if (!models.empty() && std::ranges::any_of(seats, IsOpenRouter)) {
const Json prices = FetchOpenRouterPrices(models, log);
for (auto it = prices.begin(); it != prices.end(); ++it) {
const Json& p = it.value();
@ -569,22 +802,33 @@ namespace Crafter::Review::SeatPool {
}
const std::string lensModel = fable.value_or(std::string(Seats::ProbeModel));
for (Json& s : seats) {
if (s.at("state") == "out") continue;
const std::string kind = Kind(s);
if (s.at("state") == "out" && !(IsPaid(s) && Dyn::Get(s, "ledgerOut") == "credit")) continue;
const std::string id = Id(s);
const std::string secret = Dyn::StrOr(Dyn::Get(s, "secret"), "");
if (kind == "openrouter") {
const Json r = ProbeOpenRouter(secret, timeout);
// The ledger's verdict before the probe.
const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json();
const double probedAt = NowOr(now);
const Json r = probeKey ? probeKey(secret, timeout) : ProbeOpenRouter(secret, timeout);
ApplyProbe(s, r);
s["remainingUsd"] = Dyn::Get(r, "remaining");
AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log);
log(std::format("seat {} ({}): {}", id, kind, Describe(s)));
continue;
}
const std::int32_t n = kind == "api" ? static_cast<std::int32_t>(Dyn::ParseInt(Dyn::Slice(id, 3)).value_or(0)) : (kind == "local" ? 0 : static_cast<std::int32_t>(Dyn::ParseInt(id).value_or(0)));
Json r;
if (kind == "api") {
r = run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true);
} else if (kind == "local") {
// The ledger's verdict before the probe.
const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json();
const double probedAt = NowOr(now);
ApplyProbe(s, run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true));
AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log);
log(std::format("seat {} ({}): {}", id, kind, Describe(s)));
continue;
}
if (kind == "local") {
r = run(n, "", lensModel, timeout, claudeBin, true, false);
} else {
r = run(n, secret, lensModel, timeout, claudeBin, false, false);

View file

@ -298,6 +298,20 @@ namespace Crafter::Review::Seats {
return out;
}
std::map<std::int32_t, std::int32_t> ApiGrantDays(const std::map<std::string, std::string>& env) {
std::map<std::int32_t, std::int32_t> out;
for (std::int32_t i = 1; i <= MaxSeats; ++i) {
// Decimal digits of any script; past 31 the value stays out of
// range.
const std::u32string value = Dyn::Decode(Dyn::Strip(EnvOf(env, std::format("ANTHROPIC_API_KEY{}_GRANT_DAY", i))));
if (value.empty() || !std::ranges::all_of(value, [](char32_t c) { return Dyn::DecimalValue(c) >= 0; })) continue;
std::int32_t day = 0;
for (const char32_t c : value) day = std::min(day * 10 + Dyn::DecimalValue(c), 32);
if (day >= 1 && day <= 31) out[i] = day;
}
return out;
}
std::vector<std::pair<std::int32_t, std::string>> OpenRouterKeysFromEnv(const std::map<std::string, std::string>& env) {
std::vector<std::pair<std::int32_t, std::string>> out;
for (std::int32_t i = 1; i <= MaxSeats; ++i) {
@ -341,7 +355,9 @@ namespace Crafter::Review::Seats {
for (const auto& [name, value] : DotenvLines(text)) {
for (const std::string_view stem : {std::string_view("CLAUDE_CODE_OAUTH_TOKEN"), std::string_view("ANTHROPIC_API_KEY"), std::string_view("OPENROUTER_KEY")}) {
if (!name.starts_with(stem)) continue;
const std::string_view rest = std::string_view(name).substr(stem.size());
std::string_view rest = std::string_view(name).substr(stem.size());
// ANTHROPIC_API_KEY<n>_GRANT_DAY: the seat's grant day.
if (stem == "ANTHROPIC_API_KEY" && rest.size() == 11 && rest.ends_with("_GRANT_DAY")) rest = rest.substr(0, 1);
const bool digit = rest.size() == 1 && rest[0] >= '1' && rest[0] <= '6';
if (!rest.empty() && !digit) break;
if (stem == "ANTHROPIC_API_KEY" && !digit) break;
@ -980,26 +996,54 @@ namespace Crafter::Review::Seats {
return Dyn::Join(lines, "\n");
}
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, const Json& board, const std::map<std::int32_t, std::string>& keys) {
std::vector<std::vector<std::string>> rows = {{"seat", "state", "driver spend this cycle", "procs", "cycle from", "ledger: out", "in flight"}};
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, double now, const Json& board, const std::map<std::int32_t, std::string>& keys) {
const std::string today = Platform::LocalTime("%Y-%m-%d", now);
// "17 Sept"
auto date = [](const std::string& iso) {
return std::format("{} {}", std::stoi(iso.substr(8, 2)), Months[static_cast<std::size_t>(std::stoi(iso.substr(5, 2)) - 1)]);
};
std::vector<std::vector<std::string>> rows = {{"seat", "probe", "driver", "driver spend this cycle", "procs", "cycle from", "grant day", "next credits", "in flight"}};
std::vector<std::string> notes;
for (const Json& s : SortedBySeat(apiSeats)) {
const std::string id = std::format("api{}", Dyn::Str(Dyn::At(s, "seat")));
const Json& ledger = Dyn::Or(Dyn::Get(s, "ledger"), Dyn::EmptyDict());
const double spent = NumberOr0(Dyn::Get(ledger, "spentUsd"));
const std::string state = Dyn::Str(Dyn::At(s, "state")) + (Dyn::Truthy(Dyn::Get(s, "error")) && Dyn::At(s, "state") != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string());
const Json& outSince = Dyn::Get(ledger, "outSince");
const std::string state = Dyn::Str(Dyn::At(s, "state"));
const std::string probe = state + (Dyn::Truthy(Dyn::Get(s, "error")) && state != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string());
// The driver's verdict, SeatPool::LedgerOut's.
const Json& ledgerOut = Dyn::Get(s, "ledgerOut");
const bool out = !ledgerOut.is_null();
const std::string driver = !out ? std::string("ok")
: ledgerOut == "credit" ? std::format("out (credit since {})", Dyn::Replace(Dyn::Slice(Dyn::Str(Dyn::Get(ledger, "outSince")), 0, 16), "T", " "))
: std::string("out (budget spent)");
const std::string due = Dyn::StrOr(Dyn::Get(s, "nextCredits"), "");
const std::string dueText = due.size() < 10 ? std::string("—") : due == today ? std::string("today") : due < today ? std::format("overdue, {}", date(due)) : date(due);
const std::int64_t n = Inflight(board, keys, s);
rows.push_back({
std::format("api{}", Dyn::Str(Dyn::At(s, "seat"))), state, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"),
Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::Truthy(outSince) ? std::format("since {}", Dyn::Replace(Dyn::Slice(Dyn::Str(outSince), 0, 16), "T", " ")) : std::string("—"),
n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—"),
});
rows.push_back({id, probe, driver, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"),
Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::StrOr(Dyn::Get(s, "grantDay"), "—"), dueText,
n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—")});
const Json& event = Dyn::Get(s, "event");
if (event == "new-grant") {
notes.push_back(std::format("{}: the key answers again - new credits; the ledger cycle restarted (from {})", id, Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—")));
} else if (event == "credit-gone") {
notes.push_back(std::format("{}: the credit error, now recorded in the ledger", id));
} else if (state == "ok" && ledgerOut == "credit") {
notes.push_back(std::format("{}: the key answered, but the credit error was recorded since the probe started - the ledger's verdict stands", id));
} else if (state == "ok" && out) {
notes.push_back(std::format("{}: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)", id));
} else if (state != "ok" && state != "out" && out) {
notes.push_back(std::format("{}: probe {} - the ledger's verdict stands", id, state));
}
}
return Dyn::Join(std::vector<std::string>{
RenderTable(rows),
std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · cycle day {} · {} processes per seat · placed at +${} each",
Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))),
"the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent",
}, "\n");
std::vector<std::string> lines = {RenderTable(rows)};
lines.insert(lines.end(), notes.begin(), notes.end());
lines.push_back(std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · a cycle starts on the seat's grant day (ANTHROPIC_API_KEY<n>_GRANT_DAY, else "
"REVIEW_GATES_API_CYCLE_DAY={}) · {} processes per seat · placed at +${} each",
Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")),
Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))));
lines.emplace_back("the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent, and \"overdue\" means the grant day "
"is wrong");
return Dyn::Join(lines, "\n");
}
std::string RenderDrivers(const Json& board, double now, const Zone& zone) {