feat(seats): an API seat's cycle starts on its own grant day
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:
parent
c6b4d78245
commit
ceb87f70ef
12 changed files with 774 additions and 83 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue