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
|
|
@ -37,6 +37,12 @@ model=
|
|||
prev=
|
||||
for a in "$@"; do [ "$prev" = --model ] && model=$a; prev=$a; done
|
||||
cat > /dev/null
|
||||
# A Console key whose credit is gone, answering after two seconds.
|
||||
if [ "$ANTHROPIC_API_KEY" = sk-ant-api-spent ]; then
|
||||
sleep 2
|
||||
echo '{"type":"result","subtype":"success","is_error":true,"result":"Credit balance is too low","total_cost_usd":0}'
|
||||
exit 1
|
||||
fi
|
||||
now=$(date +%s)
|
||||
fable=
|
||||
[ "$model" = claude-fable-5-1 ] && fable=",\"seven_day_overage_included\":{\"utilization\":0.3,\"resetsAt\":$((now+172800))}"
|
||||
|
|
@ -340,7 +346,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
|
|||
// --env (a prefix of --env-file) names the file for the tokens and
|
||||
// the settings alike, with ./.env in the way (the API seat's section
|
||||
// is where the order shows).
|
||||
Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nREVIEW_GATES_SEAT_ORDER=oauth-first\n");
|
||||
Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=16\nREVIEW_GATES_SEAT_ORDER=oauth-first\n");
|
||||
const Process::Captured other = Self({"seats", "--no-fable", "--env", "other.env"}, base / "seats", withFake);
|
||||
// An application's .env (no seat line, no setting) is passed over for
|
||||
// the machine's own seats file.
|
||||
|
|
@ -365,8 +371,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
|
|||
Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board; an application's .env "
|
||||
"is passed over for the machine's own file; a FIFO is read once, its settings and its seats alike",
|
||||
report.returnCode == 0 && report.out.starts_with("seat pool: 3 seat(s) (local, 1, api1) from .env and your own login, probed ")
|
||||
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running")
|
||||
&& other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | $0.00 of $200")
|
||||
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | ok | $0.00 of $200") && report.out.contains("drivers: none running")
|
||||
&& other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | ok | $0.00 of $200") && other.out.contains("| 16 |")
|
||||
&& other.out.contains("oauth seats run first (REVIEW_GATES_SEAT_ORDER=oauth-first)") && !other.out.contains("tok-two") && !other.out.contains("sk-ant-api-two")
|
||||
&& appSeats.returnCode == 0 && appSeats.out.starts_with(std::format("seat pool: 1 seat(s) (3) from {}, probed ", (base / "app-xdg" / "crafter-review" / "env").string()))
|
||||
&& fromFifo.returnCode == 0 && fromFifo.out.starts_with("seat pool: 2 seat(s) (4, api4) from seats.fifo, probed ")
|
||||
|
|
@ -377,6 +383,29 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
|
|||
&& parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z")
|
||||
&& !(report.out + json.out).contains("tok-one") && !(report.out + json.out).contains("sk-ant-api-one"),
|
||||
json.out.substr(0, 600) + json.err);
|
||||
// The credit error a probe reads is recorded in the ledger stamped
|
||||
// when it is written, not at the start the probes share (1.1.0 round
|
||||
// 3): the spent key answers two seconds after the command began.
|
||||
Write(base / "spent" / ".env", "ANTHROPIC_API_KEY3=sk-ant-api-spent\n");
|
||||
const double spentStart = Platform::Now();
|
||||
const Process::Captured spent = Self({"seats", "--json"}, base / "spent", withFake);
|
||||
Json spentParsed;
|
||||
try {
|
||||
spentParsed = Json::parse(spent.out);
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
// The child's TZ is UTC (Env): the stamp is compared in UTC.
|
||||
const auto utc = [](double epoch) {
|
||||
return std::format("{:%Y-%m-%dT%H:%M:%S}", std::chrono::floor<std::chrono::seconds>(std::chrono::sys_seconds(std::chrono::seconds(static_cast<std::int64_t>(epoch)))));
|
||||
};
|
||||
Json spentEntry;
|
||||
if (spentParsed.is_object() && spentParsed.contains("apiSeats") && !spentParsed.at("apiSeats").empty()) {
|
||||
spentEntry = Dyn::Get(Dyn::Get(spentParsed.at("apiSeats").at(0), "ledger"), "outSince");
|
||||
}
|
||||
Check("seats: a credit error the probe read is recorded in the shared ledger stamped when written, after the probe, never at the probes' common start",
|
||||
spent.returnCode == 0 && spentParsed.is_object() && spentParsed.at("apiSeats").at(0).at("event") == "credit-gone" && spentEntry.is_string()
|
||||
&& Dyn::Slice(spentEntry.get<std::string>(), 0, 19) >= utc(spentStart + 2),
|
||||
spent.out.substr(0, 800) + spent.err);
|
||||
const Process::Captured none = Self({"seats"}, base / "plain", withFake);
|
||||
const Process::Captured noFile = Self({"seats", "--env-file", "missing.env"}, base / "plain", withFake);
|
||||
Write(base / "plain" / "bad.env", std::string("\xff\n", 2));
|
||||
|
|
|
|||
|
|
@ -187,6 +187,251 @@ int main() {
|
|||
if (SeatPool::CycleStart(at, day) != want) missed.push_back(std::format("{} {} -> {}", at, day, SeatPool::CycleStart(at, day)));
|
||||
}
|
||||
Check("cycle start: this month's billing day once reached, else last month's, clamped to its length", missed.empty(), Dyn::Join(missed, "; "));
|
||||
// 2026-10-17 (the Python driver's, ported 2026-11-02): the cycle is the
|
||||
// seat's grant day - the credits' own, from the Console's expiry minus
|
||||
// three days - and a key that answered the credit error stays out across
|
||||
// the rollover until a probe sees it answer again (then: new credits, a
|
||||
// new cycle). The Python driver's vectors.
|
||||
{
|
||||
auto at = [](std::int32_t y, unsigned m, unsigned d) {
|
||||
using namespace std::chrono;
|
||||
return static_cast<double>(duration_cast<seconds>((sys_days(year_month_day(year(y), month(m), day(d))) + hours(12)).time_since_epoch()).count());
|
||||
};
|
||||
Check("cycle start: a day past the month's end is its last day",
|
||||
SeatPool::CycleStart(at(2026, 4, 30), 31) == "2026-04-30" && SeatPool::CycleStart(at(2026, 3, 1), 31) == "2026-02-28" && SeatPool::CycleStart(at(2026, 3, 30), 28) == "2026-03-28");
|
||||
constexpr double t17 = at(2026, 10, 17);
|
||||
Check("next credits: the grant day after the cycle's; the cycle's own when the seat went out on credit before it",
|
||||
SeatPool::NextGrant(t17, 17) == "2026-11-17" && SeatPool::NextGrant(t17, 5) == "2026-11-05" && SeatPool::NextGrant(t17, 17, "2026-10-08T20:19:06") == "2026-10-17"
|
||||
&& SeatPool::NextGrant(t17, 5, "2026-10-13T22:46:02") == "2026-11-05" && SeatPool::NextGrant(at(2026, 12, 20), 31) == "2026-12-31");
|
||||
Check("ledger verdict: a credit-out, the budget, else ok",
|
||||
SeatPool::LedgerVerdict({{"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}, {"spentUsd", 0}}, 200)
|
||||
== std::pair<bool, std::string>(true, "ledger: credit balance too low since 2026-10-08T20:19")
|
||||
&& SeatPool::LedgerVerdict({{"spentUsd", 200.0}}, 200) == std::pair<bool, std::string>(true, "ledger: $200.00 spent this cycle, budget $200")
|
||||
&& SeatPool::LedgerVerdict({{"spentUsd", 1}}, 200) == std::pair<bool, std::string>(false, ""));
|
||||
const std::string text2 = "ANTHROPIC_API_KEY3=sk-x\nANTHROPIC_API_KEY3_GRANT_DAY=16\nANTHROPIC_API_KEY4_GRANT_DAY = 5\nANTHROPIC_API_KEY1_GRANT_DAY=32\nANTHROPIC_API_KEY2_GRANT_DAY=x\nANTHROPIC_API_KEY_GRANT_DAY=9\n";
|
||||
Check("a grant day: decimal digits of any script, leading zeros of any length; anything else ignored",
|
||||
Seats::ApiGrantDays({{"ANTHROPIC_API_KEY1_GRANT_DAY", "0000000016"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "\uFF11\uFF16"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "1_6"},
|
||||
{"ANTHROPIC_API_KEY4_GRANT_DAY", "99999999999999999999"}, {"ANTHROPIC_API_KEY5_GRANT_DAY", "\u00B2"}, {"ANTHROPIC_API_KEY6_GRANT_DAY", "0"}})
|
||||
== std::map<std::int32_t, std::int32_t> {{1, 16}, {2, 16}});
|
||||
const std::map<std::string, std::string> tokens2 = Seats::SeatTokensFromEnvFile(text2);
|
||||
Check("the grant day per seat from the dotenv file, 1..31 only, never a key",
|
||||
tokens2 == std::map<std::string, std::string> {{"ANTHROPIC_API_KEY3", "sk-x"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "16"}, {"ANTHROPIC_API_KEY4_GRANT_DAY", "5"},
|
||||
{"ANTHROPIC_API_KEY1_GRANT_DAY", "32"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "x"}}
|
||||
&& Seats::ApiGrantDays(tokens2) == std::map<std::int32_t, std::int32_t> {{3, 16}, {4, 5}}
|
||||
&& Seats::ApiKeysFromEnv(tokens2) == std::vector<std::pair<std::int32_t, std::string>> {{3, "sk-x"}});
|
||||
SeatPool::Ledger old(td / "old.json", acfg,
|
||||
{{"api1", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}},
|
||||
{"api2", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", nullptr}, {"outReason", nullptr}}},
|
||||
{"or1", {{"cycle", "1999-01-01"}, {"spentUsd", 50}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}}});
|
||||
Check("a new cycle resets the spend; a credit-out carries over until a probe lifts it, an OpenRouter key's too (the month's turn puts nothing on it)",
|
||||
old.Entry("api1").at("spentUsd") == 0 && old.Entry("api1").at("procs") == 0 && old.Entry("api1").at("outSince") == "x" && old.Entry("api2").at("outSince").is_null()
|
||||
&& old.Entry("api2").at("spentUsd") == 0 && old.Entry("or1").at("outSince") == "x" && old.Entry("or1").at("outReason") == "y" && old.Entry("or1").at("spentUsd") == 0);
|
||||
Check("ledger out: credit, budget, else null",
|
||||
SeatPool::LedgerOut({{"outSince", "2026-10-08T20:19:06"}, {"spentUsd", 0}}, 200) == "credit" && SeatPool::LedgerOut({{"spentUsd", 200.0}}, 200) == "budget"
|
||||
&& SeatPool::LedgerOut({{"spentUsd", 1}}, 200).is_null());
|
||||
const fs::path daysEnv = td / "days.env";
|
||||
Write(daysEnv, "ANTHROPIC_API_KEY1=sk-ant-api-one\nANTHROPIC_API_KEY1_GRANT_DAY=17\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=0\n");
|
||||
SeatPool::Ledger lg(td / "days.json", acfg,
|
||||
{{"api1", {{"cycle", "2026-10-01"}, {"spentUsd", 200.18}, {"procs", 112}, {"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}}},
|
||||
{"api2", {{"cycle", "2026-09-01"}, {"spentUsd", 150.0}, {"procs", 90}, {"outSince", nullptr}, {"outReason", nullptr}}}});
|
||||
std::vector<Json> ds = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api", &lg, acfg, t17);
|
||||
Check("the grant day per seat from the seats file (0 is ignored: the global day), into the ledger",
|
||||
Ids(ds) == "api1,api2" && ds[0].at("grantDay") == 17 && ds[1].at("grantDay") == 1 && lg.Day("api1") == 17 && lg.Day("api2") == 1, Ids(ds));
|
||||
Check("rollover on the grant day: the spend starts over, a credit-out carries - the seat stays out",
|
||||
ds[0].at("state") == "out" && ds[0].at("ledgerOut") == "credit" && ds[0].at("spentUsd") == 0 && lg.Entry("api1", t17).at("cycle") == "2026-10-17"
|
||||
&& lg.Entry("api1", t17).at("procs") == 0 && ds[0].at("error").get<std::string>().contains("since 2026-10-08"),
|
||||
DumpSpaced(ds[0]));
|
||||
Check("...a budget-out resets with its cycle",
|
||||
ds[1].at("state") == "unknown" && Dyn::Get(ds[1], "ledgerOut").is_null() && ds[1].at("spentUsd") == 0 && lg.Entry("api2", t17).at("cycle") == "2026-10-01");
|
||||
std::vector<std::string> calls;
|
||||
std::map<std::string, std::string> answers = {{"api1", "ok"}, {"api2", "out"}};
|
||||
const SeatPool::ProbeFunction fake = [&](std::int32_t n, const std::string&, const std::string& model, double, const std::string&, bool, bool) {
|
||||
const std::string id = std::format("api{}", n);
|
||||
calls.push_back(id);
|
||||
const std::string state = answers.at(id);
|
||||
return Json {{"seat", n}, {"state", state}, {"windows", Json::object()}, {"probedWith", model},
|
||||
{"error", state == "ok" ? Json() : Json(state == "out" ? "Your credit balance is too low" : "probe timeout after 1000 ms")}};
|
||||
};
|
||||
std::vector<std::string> plog;
|
||||
auto plogf = [&](const std::string& line) { plog.push_back(line); };
|
||||
SeatPool::Probe(ds, "claude", 1, {"claude-opus-5-5"}, plogf, fake, &lg, t17);
|
||||
const Json e1 = lg.Entry("api1", t17);
|
||||
Check("the run-start probe re-probes a credit-out api seat; a clean answer is new credits: the seat is back, its cycle restarted on its grant day",
|
||||
calls == std::vector<std::string> {"api1", "api2"} && ds[0].at("state") == "ok" && ds[0].at("ledgerOut").is_null() && ds[0].at("error").is_null() && e1.at("outSince").is_null()
|
||||
&& e1.at("cycle") == "2026-10-17" && e1.at("grantSeen") == "2026-10-17T12:00:00+00:00"
|
||||
&& std::ranges::any_of(plog, [](const std::string& line) { return line.contains("new credits"); }),
|
||||
Dyn::Join(plog, " | "));
|
||||
const Json e2 = lg.Entry("api2", t17);
|
||||
Check("...the credit error on a seat the ledger had as fine is recorded, with the next credits in the reason",
|
||||
ds[1].at("state") == "out" && ds[1].at("ledgerOut") == "credit" && Dyn::Truthy(e2.at("outSince"))
|
||||
&& e2.at("outSince") == "2026-10-17T12:00:00+00:00" && ds[1].at("error") == "credit balance too low since 2026-10-17T12:00; next credits due 2026-11-01",
|
||||
Dyn::Str(ds[1].at("error")));
|
||||
Check("reconcile: nothing to do when the probe and the ledger agree", lg.Reconcile("api1", "ok", t17, t17).empty() && lg.Reconcile("api2", "out", t17, t17).empty());
|
||||
// Two drivers on one ledger (1.1.0 round 2): the decision is made on
|
||||
// the file's entry under the lock, never on a copy read before the
|
||||
// probe, and a mark made since the probe started stands.
|
||||
const std::string outLine = R"("cycle": "2026-10-17", "spentUsd": 0, "procs": 0, "outSince": "2026-10-08T20:19:06", "outReason": "credit balance too low")";
|
||||
Write(td / "race1.json", std::format("{{\"api1\": {{{}}}}}", outLine));
|
||||
SeatPool::Ledger la = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}});
|
||||
SeatPool::Ledger lb = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}});
|
||||
const std::string firstGrant = la.Reconcile("api1", "ok", t17, t17);
|
||||
la.Add("api1", 5.0, t17);
|
||||
const std::string secondGrant = lb.Reconcile("api1", "ok", t17 + 60, t17 + 60);
|
||||
const Json race1 = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60);
|
||||
Check("two drivers: the second's copy, read before the first saw the grant, lifts nothing - the first's grant and its spend stand",
|
||||
firstGrant == "new-grant" && secondGrant.empty() && race1.at("spentUsd") == 5.0 && race1.at("procs") == 1 && race1.at("outSince").is_null(), DumpSpaced(race1));
|
||||
Write(td / "race2.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 199.4, "procs": 180, "outSince": "2026-10-17T12:00:06", "outReason": "credit balance too low"}})");
|
||||
SeatPool::Ledger lc = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}});
|
||||
const std::string late = lc.Reconcile("api1", "ok", t17, t17 + 30);
|
||||
const Json race2 = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 30);
|
||||
Check("a credit error recorded after the probe started outlives its answer: no grant, the spend kept",
|
||||
late.empty() && race2.at("outSince") == "2026-10-17T12:00:06" && race2.at("spentUsd") == 199.4 && race2.at("procs") == 180, DumpSpaced(race2));
|
||||
Write(td / "race3.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-10-17T12:00:10"}})");
|
||||
SeatPool::Ledger ld = SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}});
|
||||
const std::string stale = ld.Reconcile("api1", "out", t17, t17 + 30);
|
||||
const std::string fresh = ld.Reconcile("api1", "out", t17 + 60, t17 + 60);
|
||||
Check("...and a credit error read before a grant another driver saw since records nothing; one read after it does",
|
||||
stale.empty() && fresh == "credit-gone" && SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60).at("spentUsd") == 2);
|
||||
// The grant seen at 23:59:55, the cycle turning at midnight (grant
|
||||
// day 17) before a credit error read at 23:59:40 is reconciled.
|
||||
Write(td / "race6.json", R"({"api1": {"cycle": "2026-11-16", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-11-16T23:59:55"}})");
|
||||
SeatPool::Ledger le = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}});
|
||||
const std::string acrossMidnight = le.Reconcile("api1", "out", at(2026, 11, 16) + 43180, at(2026, 11, 17) - 43198);
|
||||
const Json race6 = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}}).Entry("api1", at(2026, 11, 17) - 43198);
|
||||
// The stamps carry their UTC offset and Reconcile orders marks by
|
||||
// instant (1.1.0 round 4). A POSIX rule, not a zone file: Central
|
||||
// European time, whose summer time ends 2026-10-25 03:00, 02:00-03:00
|
||||
// then passing twice.
|
||||
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
|
||||
tzset();
|
||||
constexpr double fallBack = at(2026, 10, 25) - 39610;
|
||||
Write(td / "race7.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 150, "procs": 120, "outSince": null, "outReason": null}})");
|
||||
SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", fallBack + 15);
|
||||
const std::string afterFallBack = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", fallBack, fallBack + 16);
|
||||
const Json race7 = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Entry("api1", fallBack + 16);
|
||||
// A stamp without an offset, as older drivers wrote, is local time:
|
||||
// 13:30 CEST is 11:30 UTC, before a probe started at 12:00 UTC (14:00
|
||||
// CEST) - new credits; read as UTC it would be after the probe.
|
||||
Write(td / "race9.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 3, "procs": 1, "outSince": "2026-10-17T13:30:00", "outReason": "credit balance too low"}})");
|
||||
const std::string legacy = SeatPool::Ledger::Load(td / "race9.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30);
|
||||
setenv("TZ", "UTC", 1);
|
||||
tzset();
|
||||
Check("a credit error recorded after the probe started outlives its answer in the hour DST repeats: 02:00:05 CET is after 02:59:50 CEST",
|
||||
afterFallBack.empty() && race7.at("outSince") == "2026-10-25T02:00:05+01:00" && race7.at("spentUsd") == 150, DumpSpaced(race7));
|
||||
Write(td / "race8.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": "2026-10-17T11:00:06-01:00", "outReason": "credit balance too low"}})");
|
||||
const std::string otherZone = SeatPool::Ledger::Load(td / "race8.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30);
|
||||
Check("...and from a driver in another zone: its 11:00:06-01:00 is after a probe started at 12:00:00 UTC", otherZone.empty());
|
||||
Check("a stamp without an offset, as older drivers wrote, is read as local time: 13:30 CEST is before a probe started at 14:00 CEST", legacy == "new-grant", legacy);
|
||||
Write(td / "race10.json", std::format("{{\"api1\": {{{}}}}}", outLine));
|
||||
const std::string race10Before = Platform::ReadFile(td / "race10.json").value_or("");
|
||||
const std::string unread = SeatPool::Ledger::Load(td / "race10.json", acfg, {{"api1", 17}}).Reconcile("api1", "unknown", t17, t17);
|
||||
Check("a reading neither ok nor the credit error writes nothing", unread.empty() && Platform::ReadFile(td / "race10.json").value_or("") == race10Before);
|
||||
// OpenRouter keys (1.1.0, Jorijn: a prepaid key has no cycle of its
|
||||
// own): one the ledger holds out on credit is probed again through
|
||||
// the key probe, and room on its limit lifts the mark with its
|
||||
// cycle's spend kept; the limit reached is recorded; one out on its
|
||||
// budget is not probed. Their cycle is the cycle day's.
|
||||
Write(td / "or.env", "OPENROUTER_KEY1=or-one\nOPENROUTER_KEY2=or-two\nOPENROUTER_KEY3=or-three\n");
|
||||
Write(td / "or-ledger.json", R"({"or1": {"cycle": "2026-10-01", "spentUsd": 7.5, "procs": 3, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"},
|
||||
"or2": {"cycle": "2026-10-01", "spentUsd": 1, "procs": 1, "outSince": null, "outReason": null},
|
||||
"or3": {"cycle": "2026-10-01", "spentUsd": 150, "procs": 90, "outSince": null, "outReason": null}})");
|
||||
SeatPool::Ledger orLedger = SeatPool::Ledger::Load(td / "or-ledger.json", acfg);
|
||||
std::vector<Json> orSeats = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or.env").string(), td)), "openrouter", &orLedger, acfg, t17);
|
||||
std::vector<std::string> keyCalls;
|
||||
const SeatPool::OpenRouterProbeFunction fakeKey = [&](const std::string& secret, double) {
|
||||
keyCalls.push_back(secret);
|
||||
return secret == "or-one" ? Json {{"state", "ok"}, {"remaining", 3.0}} : Json {{"state", "out"}, {"error", "key limit reached ($10.00 of $10 used)"}, {"remaining", 0}};
|
||||
};
|
||||
plog.clear();
|
||||
SeatPool::Probe(orSeats, "claude", 1, {}, plogf, fake, &orLedger, t17, fakeKey);
|
||||
SeatPool::Ledger orFile = SeatPool::Ledger::Load(td / "or-ledger.json", acfg);
|
||||
const Json or1 = orFile.Entry("or1", t17);
|
||||
const Json or2 = orFile.Entry("or2", t17);
|
||||
Check("an OpenRouter key held out on credit is probed again, one out on its budget is not; room on its limit lifts the mark and keeps its cycle's spend",
|
||||
Ids(orSeats) == "or1,or2,or3" && keyCalls == std::vector<std::string> {"or-one", "or-two"} && orSeats[0].at("state") == "ok" && orSeats[0].at("ledgerOut").is_null()
|
||||
&& orSeats[0].at("spentUsd") == 7.5 && or1.at("outSince").is_null() && or1.at("spentUsd") == 7.5 && or1.at("procs") == 3 && or1.at("cycle") == "2026-10-01"
|
||||
&& or1.at("grantSeen") == "2026-10-17T12:00:00+00:00" && std::ranges::any_of(plog, [](const std::string& line) { return line.contains("limit has room again"); })
|
||||
&& orSeats[2].at("state") == "out" && orSeats[2].at("ledgerOut") == "budget",
|
||||
std::format("{} {}", DumpSpaced(or1), Dyn::Join(keyCalls, ",")));
|
||||
Check("...and the limit reached is recorded as the key's credit-out, the probe's error the seat's reason",
|
||||
orSeats[1].at("state") == "out" && orSeats[1].at("ledgerOut") == "credit" && orSeats[1].at("error") == "key limit reached ($10.00 of $10 used)"
|
||||
&& or2.at("outSince") == "2026-10-17T12:00:00+00:00" && or2.at("outReason") == "key limit reached",
|
||||
DumpSpaced(or2));
|
||||
// A key that answers with its cycle's budget spent is out on that
|
||||
// budget, not left ok - neither placed nor out, a pool of it waiting
|
||||
// forever (1.1.0 round 9): an OpenRouter key held out on credit with
|
||||
// its spend over the budget, and an API key another driver lifted and
|
||||
// spent the budget on since this copy was read.
|
||||
Write(td / "or-spent.env", "OPENROUTER_KEY1=or-one\n");
|
||||
Write(td / "or-spent.json",
|
||||
R"({"or1": {"cycle": "2026-10-01", "spentUsd": 100.4, "procs": 40, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"}})");
|
||||
SeatPool::Ledger spentLedger = SeatPool::Ledger::Load(td / "or-spent.json", acfg);
|
||||
std::vector<Json> spentSeats =
|
||||
SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or-spent.env").string(), td)), "openrouter", &spentLedger, acfg, t17);
|
||||
SeatPool::Probe(spentSeats, "claude", 1, {}, plogf, fake, &spentLedger, t17, fakeKey);
|
||||
Check("an OpenRouter key held out on credit with its budget spent too: the mark lifted, the seat out on the budget, a pool of it all out",
|
||||
spentSeats[0].at("state") == "out" && spentSeats[0].at("ledgerOut") == "budget" && SeatPool::AllOut(spentSeats)
|
||||
&& SeatPool::Ledger::Load(td / "or-spent.json", acfg).Entry("or1", t17).at("outSince").is_null(),
|
||||
DumpSpaced(spentSeats[0]));
|
||||
Write(td / "race11.json", std::format("{{\"api1\": {{{}}}}}", outLine));
|
||||
SeatPool::Ledger stale11(SeatPool::Ledger::Load(td / "race11.json", acfg));
|
||||
std::vector<Json> mine11 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale11, acfg, t17);
|
||||
SeatPool::Ledger other11 = SeatPool::Ledger::Load(td / "race11.json", acfg, {{"api1", 17}});
|
||||
other11.Reconcile("api1", "ok", t17, t17);
|
||||
other11.Add("api1", 12.0, t17);
|
||||
answers["api1"] = "ok";
|
||||
SeatPool::Probe(mine11, "claude", 1, {}, plogf, fake, &stale11, t17 + 60);
|
||||
Check("...and an API key another driver lifted and spent its budget on since: out on the budget",
|
||||
mine11[0].at("state") == "out" && mine11[0].at("ledgerOut") == "budget" && mine11[0].at("spentUsd") == 12.0 && SeatPool::AllOut(mine11), DumpSpaced(mine11[0]));
|
||||
Check("an OpenRouter key's cycle starts on the cycle day", orLedger.Day("or1") == 1 && SeatPool::Ledger::Load(td / "or-ledger.json", acfg).Entry("or1", at(2026, 11, 2)).at("cycle") == "2026-11-01");
|
||||
Check("...across the cycle's turn too: the grant's time carries over the rollover with an API seat's entry",
|
||||
acrossMidnight.empty() && race6.at("outSince").is_null() && race6.at("grantSeen") == "2026-11-16T23:59:55" && race6.at("cycle") == "2026-11-17", DumpSpaced(race6));
|
||||
Write(td / "race4.json", std::format("{{\"api1\": {{{}}}}}", outLine));
|
||||
SeatPool::Ledger stale4(SeatPool::Ledger::Load(td / "race4.json", acfg));
|
||||
std::vector<Json> mine = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale4, acfg, t17);
|
||||
SeatPool::Ledger other4 = SeatPool::Ledger::Load(td / "race4.json", acfg, {{"api1", 17}});
|
||||
other4.Reconcile("api1", "ok", t17, t17);
|
||||
other4.Add("api1", 5.0, t17);
|
||||
answers["api1"] = "ok";
|
||||
plog.clear();
|
||||
SeatPool::Probe(mine, "claude", 1, {}, plogf, fake, &stale4, t17 + 60);
|
||||
Check("the run's probe after another driver's grant: the seat is back with that driver's spend, no second grant",
|
||||
mine[0].at("state") == "ok" && mine[0].at("spentUsd") == 5.0 && mine[0].at("ledgerOut").is_null()
|
||||
&& std::ranges::none_of(plog, [](const std::string& line) { return line.contains("new credits"); })
|
||||
&& SeatPool::Ledger::Load(td / "race4.json", acfg).Entry("api1", t17 + 60).at("spentUsd") == 5.0,
|
||||
DumpSpaced(mine[0]));
|
||||
Write(td / "race5.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null}})");
|
||||
SeatPool::Ledger mine5(SeatPool::Ledger::Load(td / "race5.json", acfg));
|
||||
std::vector<Json> seats5 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &mine5, acfg, t17);
|
||||
SeatPool::Ledger::Load(td / "race5.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", t17 + 6);
|
||||
SeatPool::Probe(seats5, "claude", 1, {}, plogf, fake, &mine5, t17);
|
||||
Check("...and the run's probe that answered before another driver's credit error: the seat is out on the ledger's verdict",
|
||||
seats5[0].at("state") == "out" && seats5[0].at("ledgerOut") == "credit" && seats5[0].at("error") == "ledger: credit balance too low since 2026-10-17T12:00",
|
||||
DumpSpaced(seats5[0]));
|
||||
calls.clear();
|
||||
answers["api1"] = "unknown";
|
||||
Json apiB = With(ds[1], {{"state", "out"}, {"ledgerOut", "budget"}, {"error", "ledger: $10.00 spent this cycle, budget $10"}});
|
||||
Json apiU = With(ds[0], {{"state", "out"}, {"ledgerOut", "credit"}, {"error", "ledger: credit balance too low since 2026-10-08T20:19"}});
|
||||
std::vector<Json> pair2 = {apiB, apiU};
|
||||
SeatPool::Probe(pair2, "claude", 1, {}, plogf, fake, nullptr, t17);
|
||||
Check("a budget-out seat is not probed; a credit-out seat whose probe is unreadable stays out on the ledger's verdict",
|
||||
calls == std::vector<std::string> {"api1"} && pair2[0].at("state") == "out" && pair2[1].at("state") == "out"
|
||||
&& pair2[1].at("error") == "ledger: credit balance too low since 2026-10-08T20:19 (probe unreadable: probe timeout after 1000 ms)",
|
||||
Dyn::Str(pair2[1].at("error")));
|
||||
// A grant's cycle, through the production path: the credit error
|
||||
// recorded, then a probe that started after it answers.
|
||||
lg.SetDay("api3", 5);
|
||||
lg.MarkOut("api3", "credit balance too low", t17 - 60);
|
||||
const std::string grant3 = lg.Reconcile("api3", "ok", t17, t17);
|
||||
Check("new credits seen 12 days after the configured day: the cycle starts today, not on that day",
|
||||
grant3 == "new-grant" && lg.Entry("api3", t17).at("cycle") == "2026-10-17" && lg.Entry("api3", t17).at("spentUsd") == 0);
|
||||
lg.SetDay("api4", 16);
|
||||
lg.MarkOut("api4", "credit balance too low", t17 - 60);
|
||||
const std::string grant4 = lg.Reconcile("api4", "ok", t17, t17);
|
||||
Check("...and on the day when it is within the week", grant4 == "new-grant" && lg.Entry("api4", t17).at("cycle") == "2026-10-16"
|
||||
&& lg.Entry("api4", at(2026, 11, 15)).at("cycle") == "2026-10-16"
|
||||
&& lg.Entry("api4", at(2026, 11, 16)).at("cycle") == "2026-11-16");
|
||||
}
|
||||
const std::string knobs = DumpSpaced(SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", " 1_0 "},
|
||||
{"REVIEW_GATES_API_CONCURRENCY", "3.9"},
|
||||
{"REVIEW_GATES_API_CYCLE_DAY", "40"},
|
||||
|
|
@ -194,7 +439,7 @@ int main() {
|
|||
{"REVIEW_GATES_OPENROUTER_BUDGET_USD", "-1"},
|
||||
{"REVIEW_GATES_API_LENS_COST_USD", "nan"}}));
|
||||
Check("api config: float() parsing, int() truncation, the cycle day clamped, a negative or NaN value the default",
|
||||
knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 28, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs);
|
||||
knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 31, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs);
|
||||
const double cost = SeatPool::OpenRouterCost("openrouter/xiaomi/mimo-v2.6-pro", {{"input", 1234}, {"cacheCreation", 10}, {"cacheRead", 99999}, {"output", 777}});
|
||||
Check("openrouter cost: tokens x the built-in list prices, an unknown model free", cost == 0.001577 && SeatPool::OpenRouterCost("nope", {{"input", 1}}) == 0.0, Dyn::FloatRepr(cost));
|
||||
Json described = SeatPool::NewSeat("1", "oauth", "t");
|
||||
|
|
@ -222,7 +467,7 @@ int main() {
|
|||
lines == std::vector<std::pair<std::string, std::string>> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}, {"CLAUDE_CODE_OAUTH_TOKEN1", "x"}, {"REVIEW_GATES", "nope"}}
|
||||
&& settings == std::map<std::string, std::string> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}}
|
||||
&& Seats::SeatTokensFromEnvFile("ANTHROPIC_API_KEY=bare\nANTHROPIC_API_KEY2=k\nANTHROPIC_API_KEY2_GRANT_DAY=17\nCLAUDE_CODE_OAUTH_TOKEN=t\nCLAUDE_CODE_OAUTH_TOKEN7=no\n")
|
||||
== std::map<std::string, std::string> {{"ANTHROPIC_API_KEY2", "k"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}});
|
||||
== std::map<std::string, std::string> {{"ANTHROPIC_API_KEY2", "k"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "17"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}});
|
||||
const fs::path envTd = fs::temp_directory_path() / std::format("crafter-review-envfile-{}", Platform::PublishedPid());
|
||||
fs::remove_all(envTd);
|
||||
fs::create_directories(envTd / "repo");
|
||||
|
|
@ -248,7 +493,7 @@ int main() {
|
|||
&& !applied.env.contains("REVIEW_GATES_CLAUDE_BIN") && !applied.env.contains("REVIEW_GATES_SEAT_POLICY") && api.at("order") == "oauth-first" && api.at("budgetUsd") == 75.0
|
||||
&& !Platform::GetEnv("REVIEW_GATES_SEAT_ORDER") && !Platform::GetEnv("REVIEW_GATES_CLAUDE_BIN") && Platform::GetEnv("REVIEW_GATES_API_BUDGET_USD") == "75"
|
||||
&& SeatPool::SettingsOf(SeatPool::ResolveSeatsFile(std::string(), envTd / "repo")).fromFile.empty() && SeatPool::SettingsOf(missing).fromFile.empty() && raised == "FileNotFoundError",
|
||||
Dyn::Str(api) + " " + raised);
|
||||
std::format("{} {}", Dyn::Str(api), raised));
|
||||
// Every name on the list moves a placement reader (the list is the
|
||||
// readers' names, not a guess at them).
|
||||
const Json apiDefault = SeatPool::ApiConfig({});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
// Seat usage: the pure functions against reference vectors, numbered in
|
||||
// order, so "check 17" always names the same vector.
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
import std;
|
||||
import Crafter.Review;
|
||||
namespace fs = std::filesystem;
|
||||
|
|
@ -45,6 +47,10 @@ namespace {
|
|||
}
|
||||
|
||||
int main() {
|
||||
// The API report's dates (NextGrant, its "today") are the process zone's,
|
||||
// as for dt.date.fromtimestamp: the vectors below are UTC's.
|
||||
setenv("TZ", "UTC", 1);
|
||||
tzset();
|
||||
const Zone utc = ZoneNamed("UTC");
|
||||
const Zone ams = ZoneNamed("Europe/Amsterdam");
|
||||
// 2026-09-07 10:00 UTC
|
||||
|
|
@ -232,6 +238,44 @@ int main() {
|
|||
Eq(ClipReason("token sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123 and jorijn@example.com x\n y", secret), "token [redacted:token] and [redacted:email] x y");
|
||||
Eq(ClipReason("bad lit-secret here", secret), "bad [redacted:token] here");
|
||||
Eq(LooksLikeAuthFailure({{"api_error_status", 401}}, ""), true);
|
||||
// The API report (2026-10-17, ported 2026-11-02): the probe beside the
|
||||
// driver's verdict, the cycle, the grant day and the next credits (today,
|
||||
// a date, overdue) - the Python driver's vectors.
|
||||
{
|
||||
const Json acfg = SeatPool::ApiConfig({});
|
||||
auto api = [&](std::int32_t n, const std::string& state, const Json& error, std::int32_t day, const Json& ledger, const Json& event = nullptr) {
|
||||
Json s = {{"seat", n}, {"state", state}, {"error", error}, {"grantDay", day}, {"ledger", ledger}, {"ledgerOut", SeatPool::LedgerOut(ledger, acfg.at("budgetUsd").get<double>())},
|
||||
{"nextCredits", SeatPool::NextGrant(now, day, Dyn::Get(ledger, "outSince"))}};
|
||||
if (!event.is_null()) s["event"] = event;
|
||||
return s;
|
||||
};
|
||||
auto entry = [](const std::string& cycle, double spent, std::int32_t procs, const Json& outSince) {
|
||||
return Json {{"cycle", cycle}, {"spentUsd", spent}, {"procs", procs}, {"outSince", outSince}, {"outReason", outSince.is_null() ? Json() : Json("credit balance too low")}};
|
||||
};
|
||||
const std::string report = RenderApiReport({api(1, "out", "Your credit balance is too low", 7, entry("2026-09-07", 0.0, 0, "2026-08-20T20:19:06")),
|
||||
api(3, "ok", nullptr, 7, entry("2026-09-07", 0.0, 0, nullptr), "new-grant"), api(4, "ok", nullptr, 5, entry("2026-09-05", 200.0, 100, nullptr)),
|
||||
api(2, "unknown", "probe timeout after 30000 ms", 17, entry("2026-08-17", 12.5, 9, "2026-09-01T10:00:00")),
|
||||
api(5, "out", "Your credit balance is too low", 3, entry("2026-09-03", 0.0, 0, "2026-08-10T10:00:00"))},
|
||||
acfg, now);
|
||||
std::string flat;
|
||||
for (const char c : report) {
|
||||
if (c != ' ' || !flat.ends_with(' ')) flat += c;
|
||||
}
|
||||
Eq(flat.contains("| seat | probe | driver | driver spend this cycle | procs | cycle from | grant day | next credits |"), true);
|
||||
Eq(flat.contains("| api1 | out (Your credit balance is too low) | out (credit since 2026-08-20 20:19) | $0.00 of $200 | 0 | 2026-09-07 | 7 | today |"), true);
|
||||
Eq(flat.contains("| api2 | unknown (probe timeout after 30000 ms) | out (credit since 2026-09-01 10:00) | $12.50 of $200 | 9 | 2026-08-17 | 17 | 17 Sept |"), true);
|
||||
Eq(flat.contains("| api3 | ok | ok | $0.00 of $200 | 0 | 2026-09-07 | 7 | 7 Oct |"), true);
|
||||
Eq(flat.contains("| api4 | ok | out (budget spent) | $200.00 of $200 | 100 | 2026-09-05 | 5 | 5 Oct |"), true);
|
||||
Eq(flat.contains("| api5 | out (Your credit balance is too low) | out (credit since 2026-08-10 10:00) | $0.00 of $200 | 0 | 2026-09-03 | 3 | overdue, 3 Sept |"), true);
|
||||
std::vector<std::string> notes;
|
||||
for (const std::string& line : Lines(flat)) {
|
||||
if (line.starts_with("api") && notes.size() < 4) notes.push_back(line);
|
||||
}
|
||||
EqJson(Json(notes), Json::array({"api2: probe unknown - the ledger's verdict stands", "api3: the key answers again - new credits; the ledger cycle restarted (from 2026-09-07)",
|
||||
"api4: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)",
|
||||
"api seats run first (REVIEW_GATES_SEAT_ORDER=api-first) · budget $200 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=1) · 8 processes per seat · placed at +$1 each"}));
|
||||
}
|
||||
Eq(LooksLikeAuthFailure(nullptr, "Invalid API key · Please run /login"), true);
|
||||
Eq(LooksLikeAuthFailure({{"result", "ok"}}, ""), false);
|
||||
// find_claude_bin: CLAUDE_BIN, then PATH, then the newest editor-bundled
|
||||
|
|
|
|||
Loading…
Reference in a new issue